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.5.0
Show newest version
package com.launchdarkly.sdk.server;

import com.launchdarkly.logging.LDLogger;
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.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;
import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes;

import java.util.List;
import java.util.Set;

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;
 * 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 {
    FeatureFlag getFlag(String key);
    Segment getSegment(String key);
    BigSegmentStoreWrapper.BigSegmentsQueryResult getBigSegments(String key);
  }

  /**
   * 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 interface PrerequisiteEvaluationSink {
    void recordPrerequisiteEvaluation(
        FeatureFlag flag,
        FeatureFlag prereqOfFlag,
        LDUser user,
        EvalResult result
        );
  }
  
  /**
   * 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, 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 user the user to evaluate against
   * @param eventFactory produces feature request events
   * @return an {@link EvalResult} - guaranteed non-null
   */
  EvalResult evaluate(FeatureFlag flag, LDUser user, PrerequisiteEvaluationSink prereqEvals) {
    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());
      return EvalResult.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED);
    }

    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(FeatureFlag flag, LDUser user,
      PrerequisiteEvaluationSink prereqEvals, EvaluatorState state) {
    if (!flag.isOn()) {
      return EvaluatorHelpers.offResult(flag);
    }
    
    EvalResult prereqFailureResult = checkPrerequisites(flag, user, prereqEvals, state);
    if (prereqFailureResult != null) {
      return prereqFailureResult;
    }
    
    // Check to see if targets match
    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
    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);
      }
    }
    // Walk through the fallthrough and see if it matches
    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 EvalResult if we have to
  // short-circuit due to a prerequisite failure.
  private EvalResult checkPrerequisites(FeatureFlag flag, LDUser user,
      PrerequisiteEvaluationSink prereqEvals, EvaluatorState state) {
    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;
      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, 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.getVariationIndex() != prereq.getVariation()) {
          prereqOk = false;
        }
        if (prereqEvals != null) {
          prereqEvals.recordPrerequisiteEvaluation(prereqFeatureFlag, flag, user, prereqEvalResult);
        }
      }
      if (!prereqOk) {
        return EvaluatorHelpers.prerequisiteFailedResult(flag, prereq);
      }
    }
    return null;
  }

  private EvalResult getValueForVariationOrRollout(
      FeatureFlag flag,
      VariationOrRollout vr,
      LDUser user,
      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 = bucketUser(rollout.getSeed(), user, flag.getKey(), rollout.getBucketBy(), flag.getSalt());
        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();
            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 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;
      }
    }
    return true;
  }

  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() == 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);
            }
          }
        }
      }
      return maybeNegate(clause, false);
    }
    
    return clauseMatchesUserNoSegments(clause, user);
  }
  
  private boolean clauseMatchesUserNoSegments(Clause clause, LDUser user) {
    LDValue userValue = user.getAttribute(clause.getAttribute());
    if (userValue.isNull()) {
      return false;
    }

    if (userValue.getType() == LDValueType.ARRAY) {
      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;
        }
        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;
  }
  
  static boolean clauseMatchAny(Clause clause, LDValue userValue) {
    Operator op = clause.getOp();
    if (op != null) {
      ClausePreprocessed preprocessed = clause.preprocessed;
      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) {
          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
        ClausePreprocessed.ValueData p = preprocessedValues == null ? null : preprocessedValues.get(i);
        LDValue v = values.get(i);
        if (EvaluatorOperators.apply(op, userValue, v, p)) {
          return true;
        }
      }
    }
    return false;
  }

  private boolean maybeNegate(Clause clause, boolean b) {
    return clause.isNegate() ? !b : b;
  }
  
  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) {
        // 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;
      }

      // 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 (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
          state.bigSegmentsStatus = EvaluationReason.BigSegmentsStatus.NOT_CONFIGURED;
        } else {
          state.bigSegmentsStatus = queryResult.status;
          state.bigSegmentsMembership = queryResult.membership;
        }
      }
      Boolean membership = state.bigSegmentsMembership == null ?
          null : state.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;
      }
    }
    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;
      }
    }
    return false;
  }

  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;
      }
    }
    
    // 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(null, user, segmentKey, segmentRule.getBucketBy(), salt);
    double weight = (double)segmentRule.getWeight() / 100000.0;
    return bucket < weight;
  }

  private EvalResult computeRuleMatch(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(Segment segment) {
    return String.format("%s.g%d", segment.getKey(), segment.getGeneration());
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy