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..af49227db --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/DataModelPreprocessing.java @@ -0,0 +1,271 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.launchdarkly.sdk.EvaluationReason; +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; +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; + +/** + * 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 EvalResultsForSingleVariation { + private final EvalResult regularResult; + private final EvalResult inExperimentResult; + + EvalResultsForSingleVariation( + LDValue value, + int variationIndex, + EvaluationReason regularReason, + EvaluationReason inExperimentReason, + boolean alwaysInExperiment + ) { + this.regularResult = EvalResult.of(value, variationIndex, regularReason).withForceReasonTracking(alwaysInExperiment); + this.inExperimentResult = EvalResult.of(value, variationIndex, inExperimentReason).withForceReasonTracking(true); + } + + EvalResult getResult(boolean inExperiment) { + return inExperiment ? inExperimentResult : regularResult; + } + } + + static final class EvalResultFactoryMultiVariations { + private final ImmutableList variations; + + EvalResultFactoryMultiVariations( + ImmutableList variations + ) { + this.variations = variations; + } + + EvalResult forVariation(int index, boolean inExperiment) { + if (index < 0 || index >= variations.size()) { + return EvalResult.error(ErrorKind.MALFORMED_FLAG); + } + return variations.get(index).getResult(inExperiment); + } + } + + static final class FlagPreprocessed { + EvalResult offResult; + EvalResultFactoryMultiVariations fallthroughResults; + + FlagPreprocessed(EvalResult offResult, + EvalResultFactoryMultiVariations fallthroughResults) { + this.offResult = offResult; + this.fallthroughResults = fallthroughResults; + } + } + + static final class PrerequisitePreprocessed { + final EvalResult prerequisiteFailedResult; + + PrerequisitePreprocessed(EvalResult prerequisiteFailedResult) { + this.prerequisiteFailedResult = prerequisiteFailedResult; + } + } + + static final class TargetPreprocessed { + final EvalResult targetMatchResult; + + TargetPreprocessed(EvalResult targetMatchResult) { + this.targetMatchResult = targetMatchResult; + } + } + + static final class FlagRulePreprocessed { + final EvalResultFactoryMultiVariations allPossibleResults; + + FlagRulePreprocessed( + EvalResultFactoryMultiVariations 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( + EvaluatorHelpers.offResult(f), + precomputeMultiVariationResults(f, EvaluationReason.fallthrough(false), + EvaluationReason.fallthrough(true), f.isTrackEventsFallthrough()) + ); + + 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. + p.preprocessed = new PrerequisitePreprocessed(EvaluatorHelpers.prerequisiteFailedResult(f, p)); + } + + static void preprocessTarget(Target t, FeatureFlag f) { + // 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, r.isTrackEvents())); + + 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 EvalResultFactoryMultiVariations precomputeMultiVariationResults( + FeatureFlag f, + EvaluationReason regularReason, + EvaluationReason inExperimentReason, + boolean alwaysInExperiment + ) { + ImmutableList.Builder builder = + ImmutableList.builderWithExpectedSize(f.getVariations().size()); + for (int i = 0; i < f.getVariations().size(); i++) { + builder.add(new EvalResultsForSingleVariation(f.getVariations().get(i), i, + regularReason, inExperimentReason, alwaysInExperiment)); + } + 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: + *