All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.launchdarkly.sdk.server.Evaluator Maven / Gradle / Ivy

There is a newer version: 7.6.0
Show newest version
package com.launchdarkly.sdk.server;

import com.launchdarkly.logging.LDLogger;
import com.launchdarkly.sdk.AttributeRef;
import com.launchdarkly.sdk.ContextKind;
import com.launchdarkly.sdk.EvaluationReason;
import com.launchdarkly.sdk.EvaluationReason.ErrorKind;
import com.launchdarkly.sdk.EvaluationReason.Kind;
import com.launchdarkly.sdk.LDContext;
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.subsystems.BigSegmentStoreTypes;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static com.launchdarkly.sdk.server.EvaluatorBucketing.computeBucketValue;
import static com.launchdarkly.sdk.server.EvaluatorHelpers.contextKeyIsInTargetList;
import static com.launchdarkly.sdk.server.EvaluatorHelpers.contextKeyIsInTargetLists;
import static com.launchdarkly.sdk.server.EvaluatorHelpers.matchClauseByKind;
import static com.launchdarkly.sdk.server.EvaluatorHelpers.matchClauseWithoutSegments;
import static com.launchdarkly.sdk.server.EvaluatorHelpers.maybeNegate;

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

  /**
   * 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;
  private final LDLogger logger;

  /**
   * An abstraction of getting flags or segments by key. This ensures that Evaluator cannot modify the data store,
   * and simplifies testing.
   */
  static interface Getters {

    /**
     * @param key of the flag to get
     * @return the flag, or null if a flag with the given key doesn't exist
     */
    @Nullable
    FeatureFlag getFlag(String key);

    /**
     * @param key of the segment to get
     * @return the segment, or null if a segment with the given key doesn't exist.
     */
    @Nullable
    Segment getSegment(String key);

    BigSegmentStoreWrapper.BigSegmentsQueryResult getBigSegments(String key);
  }

  /**
   * Represents errors that should terminate evaluation, for situations where it's simpler to use throw/catch
   * than to return an error result back up a call chain.
   */
  @SuppressWarnings("serial")
  static class EvaluationException extends RuntimeException {
    final ErrorKind errorKind;

    EvaluationException(ErrorKind errorKind, String message) {
      this.errorKind = errorKind;
    }
  }

  /**
   * This object holds mutable state that Evaluator may need during an evaluation.
   */
  private static class EvaluatorState {
    private Map bigSegmentsMembership = null;
    private EvaluationReason.BigSegmentsStatus bigSegmentsStatus = null;
    private FeatureFlag originalFlag = null;
    private List prerequisiteStack = null;
    private List prerequisiteEvalRecords =  new ArrayList<>(0); // 0 initial capacity uses a static instance for performance
    private List segmentStack = null;
  }

  Evaluator(Getters getters, LDLogger logger) {
    this.getters = getters;
    this.logger = logger;
  }

  /**
   * 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 context the evaluation context
   * @param recorder records information as evaluation runs
   * @return an {@link EvalResult} - guaranteed non-null
   */
  EvalResult evaluate(FeatureFlag flag, LDContext context, @Nonnull EvaluationRecorder recorder) {
    if (flag.getKey() == INVALID_FLAG_KEY_THAT_THROWS_EXCEPTION) {
      throw EXPECTED_EXCEPTION_FROM_INVALID_FLAG;
    }

    EvaluatorState state = new EvaluatorState();
    state.originalFlag = flag;

    try {
      EvalResult result = evaluateInternal(flag, context, recorder, state);

      if (state.bigSegmentsStatus != null) {
        result = result.withReason(
            result.getReason().withBigSegmentsStatus(state.bigSegmentsStatus)
        );
      }

      if (state.prerequisiteEvalRecords != null && !state.prerequisiteEvalRecords.isEmpty()) {
        result = result.withPrerequisiteEvalRecords(state.prerequisiteEvalRecords);
      }

      return result;
    } catch (EvaluationException e) {
      logger.error("Could not evaluate flag \"{}\": {}", flag.getKey(), e.getMessage());
      return EvalResult.error(e.errorKind);
    }
  }

  /**
   * Internal evaluation function that may be called multiple times during a flag evaluation.
   *
   * @param flag that to evaluate
   * @param context to use for evaluation
   * @param recorder that will be used to record evaluation events
   * @param state for mutable values needed during evaluation
   * @return the evaluation result
   */
  private EvalResult evaluateInternal(FeatureFlag flag, LDContext context, @Nonnull EvaluationRecorder recorder, EvaluatorState state) {
    if (!flag.isOn()) {
      return EvaluatorHelpers.offResult(flag);
    }

    EvalResult prereqFailureResult = checkPrerequisites(flag, context, recorder, state);
    if (prereqFailureResult != null) {
      return prereqFailureResult;
    }

    // Check to see if targets match
    EvalResult targetMatchResult = checkTargets(flag, context);
    if (targetMatchResult != null) {
      return targetMatchResult;
    }

    // Now walk through the rules and see if any match
    List rules = flag.getRules(); // guaranteed non-null
    int nRules = rules.size();
    for (int i = 0; i < nRules; i++) {
      Rule rule = rules.get(i);
      if (ruleMatchesContext(flag, rule, context, state)) {
        return computeRuleMatch(flag, context, rule, i);
      }
    }
    // Walk through the fallthrough and see if it matches
    return getValueForVariationOrRollout(flag, flag.getFallthrough(), context,
        flag.preprocessed == null ? null : flag.preprocessed.fallthroughResults,
        EvaluationReason.fallthrough());
  }

  // 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(FeatureFlag flag, LDContext context, @Nonnull EvaluationRecorder recorder, EvaluatorState state) {
    List prerequisites = flag.getPrerequisites(); // guaranteed non-null
    int nPrerequisites = prerequisites.size();
    if (nPrerequisites == 0) {
      return null;
    }

    try {
      // We use the state object to guard against circular references in prerequisites. To avoid
      // the overhead of creating the state.prerequisiteStack list in the most common case where
      // there's only a single level prerequisites, we treat state.originalFlag as the first
      // element in the stack.
      if (flag != state.originalFlag) {
        if (state.prerequisiteStack == null) {
          state.prerequisiteStack = new ArrayList<>();
        }
        state.prerequisiteStack.add(flag.getKey());
      }

      for (int i = 0; i < nPrerequisites; i++) {
        Prerequisite prereq = prerequisites.get(i);
        String prereqKey = prereq.getKey();

        if (prereqKey.equals(state.originalFlag.getKey()) ||
            (flag != state.originalFlag && prereqKey.equals(flag.getKey())) ||
            (state.prerequisiteStack != null && state.prerequisiteStack.contains(prereqKey))) {
          throw new EvaluationException(ErrorKind.MALFORMED_FLAG,
              "prerequisite relationship to \"" + prereqKey + "\" caused a circular reference;" +
                  " this is probably a temporary condition due to an incomplete update");
        }

        boolean prereqOk = true;
        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, context, recorder, 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.getVariationIndex() != prereq.getVariation()) {
            prereqOk = false;
          }
          state.prerequisiteEvalRecords.add(new PrerequisiteEvalRecord(prereqFeatureFlag, flag, prereqEvalResult));
          recorder.recordPrerequisiteEvaluation(prereqFeatureFlag, flag, context, prereqEvalResult);
        }
        if (!prereqOk) {
          return EvaluatorHelpers.prerequisiteFailedResult(flag, prereq);
        }
      }
      return null; // all prerequisites were satisfied
    } finally {
      if (state.prerequisiteStack != null && !state.prerequisiteStack.isEmpty()) {
        state.prerequisiteStack.remove(state.prerequisiteStack.size() - 1);
      }
    }
  }

  private static EvalResult checkTargets(
      FeatureFlag flag,
      LDContext context
  ) {
    List contextTargets = flag.getContextTargets(); // guaranteed non-null
    List userTargets = flag.getTargets(); // guaranteed non-null
    int nContextTargets = contextTargets.size();
    int nUserTargets = userTargets.size();

    if (nContextTargets == 0) {
      // old-style data has only targets for users
      if (nUserTargets != 0) {
        LDContext userContext = context.getIndividualContext(ContextKind.DEFAULT);
        if (userContext != null) {
          for (int i = 0; i < nUserTargets; i++) {
            Target t = userTargets.get(i);
            if (t.getValues().contains(userContext.getKey())) { // getValues() is guaranteed non-null
              return EvaluatorHelpers.targetMatchResult(flag, t);
            }
          }
        }
      }
      return null;
    }

    // new-style data has ContextTargets, which may include placeholders for user targets that are in Targets
    for (int i = 0; i < nContextTargets; i++) {
      Target t = contextTargets.get(i);
      if (t.getContextKind() == null || t.getContextKind().isDefault()) {
        LDContext userContext = context.getIndividualContext(ContextKind.DEFAULT);
        if (userContext == null) {
          continue;
        }
        for (int j = 0; j < nUserTargets; j++) {
          Target ut = userTargets.get(j);
          if (ut.getVariation() == t.getVariation()) {
            if (ut.getValues().contains(userContext.getKey())) {
              return EvaluatorHelpers.targetMatchResult(flag, t);
            }
            break;
          }
        }
      } else {
        if (contextKeyIsInTargetList(context, t.getContextKind(), t.getValues())) {
          return EvaluatorHelpers.targetMatchResult(flag, t);
        }
      }
    }
    return null;
  }

  private EvalResult getValueForVariationOrRollout(
      FeatureFlag flag,
      VariationOrRollout vr,
      LDContext context,
      DataModelPreprocessing.EvalResultFactoryMultiVariations precomputedResults,
      EvaluationReason reason
  ) {
    int variation = -1;
    boolean inExperiment = false;
    Integer maybeVariation = vr.getVariation();
    if (maybeVariation != null) {
      variation = maybeVariation.intValue();
    } else {
      Rollout rollout = vr.getRollout();
      if (rollout != null && !rollout.getVariations().isEmpty()) {
        float bucket = computeBucketValue(
            rollout.isExperiment(),
            rollout.getSeed(),
            context,
            rollout.getContextKind(),
            flag.getKey(),
            rollout.getBucketBy(),
            flag.getSalt()
        );
        boolean contextWasFound = bucket >= 0; // see comment on computeBucketValue
        float sum = 0F;
        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();
            inExperiment = vr.getRollout().isExperiment() && !wv.isUntracked() && contextWasFound;
            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);
    }
    // Normally, we will always have precomputedResults
    if (precomputedResults != null) {
      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) {
    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 ruleMatchesContext(FeatureFlag flag, Rule rule, LDContext context, 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 (!clauseMatchesContext(clause, context, state)) {
        return false;
      }
    }
    return true;
  }

  private boolean clauseMatchesContext(Clause clause, LDContext context, EvaluatorState state) {
    if (clause.getOp() == Operator.segmentMatch) {
      return maybeNegate(clause, matchAnySegment(clause.getValues(), context, state));
    }
    AttributeRef attr = clause.getAttribute();
    if (attr == null) {
      throw new EvaluationException(ErrorKind.MALFORMED_FLAG, "rule clause did not specify an attribute");
    }
    if (!attr.isValid()) {
      throw new EvaluationException(ErrorKind.MALFORMED_FLAG,
          "invalid attribute reference \"" + attr.getError() + "\"");
    }
    if (attr.getDepth() == 1 && attr.getComponent(0).equals("kind")) {
      return maybeNegate(clause, matchClauseByKind(clause, context));
    }
    LDContext actualContext = context.getIndividualContext(clause.getContextKind());
    if (actualContext == null) {
      return false;
    }
    LDValue contextValue = actualContext.getValue(attr);
    if (contextValue.isNull()) {
      return false;
    }

    if (contextValue.getType() == LDValueType.ARRAY) {
      int nValues = contextValue.size();
      for (int i = 0; i < nValues; i++) {
        LDValue value = contextValue.get(i);
        if (matchClauseWithoutSegments(clause, value)) {
          return maybeNegate(clause, true);
        }
      }
      return maybeNegate(clause, false);
    } else if (contextValue.getType() != LDValueType.OBJECT) {
      return maybeNegate(clause, matchClauseWithoutSegments(clause, contextValue));
    }
    return false;
  }

  private boolean matchAnySegment(List values, LDContext context, EvaluatorState state) {
    // For the segmentMatch operator, the values list is really a list of segment keys. We
    // return a match if any of these segments matches the context.
    int nValues = values.size();
    for (int i = 0; i < nValues; i++) {
      LDValue clauseValue = values.get(i);
      if (!clauseValue.isString()) {
        continue;
      }
      String segmentKey = clauseValue.stringValue();
      if (state.segmentStack != null) {
        // Clauses within a segment can reference other segments, so we don't want to get stuck in a cycle.
        if (state.segmentStack.contains(segmentKey)) {
          throw new EvaluationException(ErrorKind.MALFORMED_FLAG,
              "segment rule referencing segment \"" + segmentKey + "\" caused a circular reference;" +
                  " this is probably a temporary condition due to an incomplete update");
        }
      }
      Segment segment = getters.getSegment(segmentKey);
      if (segment != null) {
        if (segmentMatchesContext(segment, context, state)) {
          return true;
        }
      }
    }
    return false;
  }

  private boolean segmentMatchesContext(Segment segment, LDContext context, EvaluatorState state) {
    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.
        state.bigSegmentsStatus = EvaluationReason.BigSegmentsStatus.NOT_CONFIGURED;
        return false;
      }
      LDContext matchContext = context.getIndividualContext(segment.getUnboundedContextKind());
      if (matchContext == null) {
        return false;
      }
      String key = matchContext.getKey();
      BigSegmentStoreTypes.Membership membershipData =
          state.bigSegmentsMembership == null ? null : state.bigSegmentsMembership.get(key);
      if (membershipData == null) {
        BigSegmentStoreWrapper.BigSegmentsQueryResult queryResult = getters.getBigSegments(key);
        if (queryResult == null) {
          // The SDK hasn't been configured to be able to use big segments
          state.bigSegmentsStatus = EvaluationReason.BigSegmentsStatus.NOT_CONFIGURED;
        } else {
          membershipData = queryResult.membership;
          state.bigSegmentsStatus = queryResult.status;
          if (state.bigSegmentsMembership == null) {
            state.bigSegmentsMembership = new HashMap<>();
          }
          state.bigSegmentsMembership.put(key, membershipData);
        }
      }
      Boolean membershipResult = membershipData == null ? null :
          membershipData.checkMembership(makeBigSegmentRef(segment));
      if (membershipResult != null) {
        return membershipResult.booleanValue();
      }
    } else {
      if (contextKeyIsInTargetList(context, ContextKind.DEFAULT, segment.getIncluded())) {
        return true;
      }
      if (contextKeyIsInTargetLists(context, segment.getIncludedContexts())) {
        return true;
      }
      if (contextKeyIsInTargetList(context, ContextKind.DEFAULT, segment.getExcluded())) {
        return false;
      }
      if (contextKeyIsInTargetLists(context, segment.getExcludedContexts())) {
        return false;
      }
    }
    List rules = segment.getRules(); // guaranteed non-null
    if (!rules.isEmpty()) {
      // Evaluating rules means we might be doing recursive segment matches, so we'll push the current
      // segment key onto the stack for cycle detection.
      if (state.segmentStack == null) {
        state.segmentStack = new ArrayList<>();
      }
      state.segmentStack.add(segment.getKey());
      int nRules = rules.size();
      try {
        for (int i = 0; i < nRules; i++) {
          SegmentRule rule = rules.get(i);
          if (segmentRuleMatchesContext(rule, context, state, segment.getKey(), segment.getSalt())) {
            return true;
          }
        }
      } finally {
        state.segmentStack.remove(state.segmentStack.size() - 1);
      }
    }
    return false;
  }

  private boolean segmentRuleMatchesContext(
      SegmentRule segmentRule,
      LDContext context,
      EvaluatorState state,
      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 (!clauseMatchesContext(c, context, state)) {
        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 context buckets in
    double bucket = computeBucketValue(
        false,
        null,
        context,
        segmentRule.getRolloutContextKind(),
        segmentKey,
        segmentRule.getBucketBy(),
        salt
    );
    double weight = (double) segmentRule.getWeight() / 100000.0;
    return bucket < weight;
  }

  private EvalResult computeRuleMatch(FeatureFlag flag, LDContext context, Rule rule, int ruleIndex) {
    if (rule.preprocessed != null) {
      return getValueForVariationOrRollout(flag, rule, context, rule.preprocessed.allPossibleResults, null);
    }
    EvaluationReason reason = EvaluationReason.ruleMatch(ruleIndex, rule.getId());
    return getValueForVariationOrRollout(flag, rule, context, null, reason);
  }

  static String makeBigSegmentRef(Segment segment) {
    return String.format("%s.g%d", segment.getKey(), segment.getGeneration());
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy