diff --git a/.gitmodules b/.gitmodules index dcaa9e9..120c3e0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "src/test/java/com/flagsmith/flagengine/enginetestdata"] path = src/test/java/com/flagsmith/flagengine/enginetestdata url = git@github.com:Flagsmith/engine-test-data.git - branch = v3.7.0 \ No newline at end of file + branch = v3.11.0 \ No newline at end of file diff --git a/src/main/java/com/flagsmith/flagengine/Engine.java b/src/main/java/com/flagsmith/flagengine/Engine.java index 269c100..43791d8 100644 --- a/src/main/java/com/flagsmith/flagengine/Engine.java +++ b/src/main/java/com/flagsmith/flagengine/Engine.java @@ -1,15 +1,26 @@ package com.flagsmith.flagengine; +import com.fasterxml.jackson.databind.ObjectMapper; import com.flagsmith.flagengine.segments.SegmentEvaluator; import com.flagsmith.flagengine.utils.Hashing; import java.math.BigDecimal; +import java.util.AbstractMap; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.ImmutablePair; -public class Engine { +public class Engine { + public static final String CIRCULAR_DEPENDENCY_REASON = "ERROR; code=CIRCULAR_DEPENDENCY"; + + private static final ObjectMapper mapper = new ObjectMapper(); + private static class SegmentEvaluationResult { List segments; HashMap> segmentFeatureContexts; @@ -30,6 +41,164 @@ public HashMap> getSegmentFeatureC } } + /** + * The `$.flags` mapping, resolving a flag when a condition first reads it. + */ + private static class LazyFlags extends AbstractMap { + private final EvaluationContext context; + private final Map resolved = new LinkedHashMap<>(); + private DependencyResolver resolver; + + /** Names of the flags found to be in a dependency cycle. */ + private final Set cyclic = new HashSet<>(); + + /** Whether a condition has read a flag, even one that resolved to nothing. */ + private boolean used = false; + + /** Incremented whenever a cycle is cut, so callers can tell a verdict rests on one. */ + private int cycleHits = 0; + + LazyFlags(EvaluationContext context) { + this.context = context; + } + + @Override + public Object get(Object key) { + if (!(key instanceof String)) { + return null; + } + String featureName = (String) key; + if (!resolved.containsKey(featureName)) { + used = true; + if (resolver == null) { + resolver = new DependencyResolver(context, this); + } + resolver.resolveFeature(featureName); + } + FlagResult flagResult = resolved.get(featureName); + return flagResult == null ? null : mapper.convertValue(flagResult, Map.class); + } + + @Override + public boolean containsKey(Object key) { + return get(key) != null; + } + + @Override + public Set> entrySet() { + return resolved.keySet().stream() + .map(featureName -> new SimpleImmutableEntry<>(featureName, get(featureName))) + .collect(Collectors.toCollection(HashSet::new)); + } + } + + /** + * Resolves overrides for a context with flag dependencies, memoising the results. + */ + private static class DependencyResolver { + private final EvaluationContext context; + private final LazyFlags flags; + private final Map segmentMatches = new HashMap<>(); + private final List resolving = new ArrayList<>(); + private final Map> segmentKeysByFeatureName = new HashMap<>(); + + DependencyResolver(EvaluationContext context, LazyFlags flags) { + this.context = context; + this.flags = flags; + context.getSegments().getAdditionalProperties().forEach((segmentKey, segmentContext) -> { + List overrides = segmentContext.getOverrides(); + if (overrides != null) { + for (FeatureContext override : overrides) { + segmentKeysByFeatureName + .computeIfAbsent(override.getName(), name -> new ArrayList<>()) + .add(segmentKey); + } + } + }); + } + + void resolveFeature(String featureName) { + int cycleStart = resolving.indexOf(featureName); + if (cycleStart != -1) { + flags.cyclic.addAll(resolving.subList(cycleStart, resolving.size())); + flags.cycleHits++; + return; + } + Features features = context.getFeatures(); + FeatureContext featureContext = features == null + ? null + : features.getAdditionalProperties().get(featureName); + if (featureContext == null) { + return; + } + + resolving.add(featureName); + ImmutablePair segmentOverride; + try { + segmentOverride = getSegmentOverride(featureName); + } finally { + resolving.remove(resolving.size() - 1); + } + + if (flags.cyclic.contains(featureName)) { + return; + } + + String identityKey = getIdentityKey(context); + if (segmentOverride != null) { + flags.resolved.put(featureName, getFlagResultFromFeatureContext( + segmentOverride.getRight(), + identityKey, + "TARGETING_MATCH; segment=" + segmentOverride.getLeft())); + } else { + flags.resolved.put(featureName, getFlagResultFromFeatureContext( + featureContext, identityKey, "DEFAULT")); + } + } + + private boolean matchesSegment(String segmentKey) { + Boolean memoised = segmentMatches.get(segmentKey); + if (memoised != null) { + return memoised; + } + + int cycleHits = flags.cycleHits; + boolean matches = SegmentEvaluator.isContextInSegment( + context, context.getSegments().getAdditionalProperties().get(segmentKey), flags); + + if (flags.cycleHits != cycleHits) { + return false; // The verdict rests on a flag left unresolved by a cycle. + } + + segmentMatches.put(segmentKey, matches); + return matches; + } + + private ImmutablePair getSegmentOverride(String featureName) { + ImmutablePair segmentOverride = null; + + for (String segmentKey : segmentKeysByFeatureName.getOrDefault(featureName, List.of())) { + if (!matchesSegment(segmentKey)) { + continue; + } + SegmentContext segmentContext = context.getSegments().getAdditionalProperties() + .get(segmentKey); + for (FeatureContext overrideFeatureContext : segmentContext.getOverrides()) { + if (!overrideFeatureContext.getName().equals(featureName)) { + continue; + } + if (winsOver(overrideFeatureContext, + segmentOverride == null ? null : segmentOverride.getRight())) { + segmentOverride = new ImmutablePair<>( + segmentContext.getName(), overrideFeatureContext); + } + } + } + + return segmentOverride; + } + } + /** * Get evaluation result for a given evaluation context. * @@ -38,9 +207,20 @@ public HashMap> getSegmentFeatureC */ public static EvaluationResult getEvaluationResult(EvaluationContext context) { context = getEnrichedEvaluationContext(context); - SegmentEvaluationResult segmentEvaluationResult = evaluateSegments(context); + LazyFlags resolved = new LazyFlags(context); + SegmentEvaluationResult segmentEvaluationResult = evaluateSegments(context, resolved); Flags flags = evaluateFeatures(context, segmentEvaluationResult.getSegmentFeatureContexts()); + if (resolved.used) { + flags.getAdditionalProperties().putAll(resolved.resolved); + for (String featureName : resolved.cyclic) { + FlagResult flagResult = flags.getAdditionalProperties().get(featureName); + if (flagResult != null) { + flagResult.setReason(CIRCULAR_DEPENDENCY_REASON); + } + } + } + return new EvaluationResult() .withFlags(flags) .withSegments(segmentEvaluationResult.getSegments()); @@ -48,7 +228,7 @@ public static EvaluationResult getEvaluationResult(EvaluationContext context) { /* * Get a version of the evaluation context enriched with derived data. - * + * * @param context Evaluation context. * @return Enriched evaluation context, or the original if no enrichment was needed. */ @@ -64,16 +244,41 @@ private static EvaluationContext getEnrichedEvaluationContext(EvaluationContext return context; } + /* + * Whether a segment override takes precedence over the one held so far. + * + * Lower priority wins, and the first seen wins a tie, so that precedence + * follows context order rather than the order segments are evaluated in. + */ + private static boolean winsOver(FeatureContext candidate, FeatureContext incumbent) { + return incumbent == null || getPriority(candidate) < getPriority(incumbent); + } + + private static double getPriority(FeatureContext featureContext) { + return featureContext.getPriority() == null + ? EngineConstants.WEAKEST_PRIORITY + : featureContext.getPriority(); + } + private static SegmentEvaluationResult evaluateSegments( - EvaluationContext context) { + EvaluationContext context, LazyFlags flags) { List segments = new ArrayList<>(); HashMap> segmentFeatureContexts = new HashMap<>(); Segments contextSegments = context.getSegments(); if (contextSegments != null) { + int cycleHits = flags.cycleHits; + for (SegmentContext segmentContext : contextSegments.getAdditionalProperties().values()) { - if (SegmentEvaluator.isContextInSegment(context, segmentContext)) { + boolean matches = SegmentEvaluator.isContextInSegment(context, segmentContext, flags); + + if (flags.cycleHits != cycleHits) { + cycleHits = flags.cycleHits; // The verdict rests on a flag left unresolved by a cycle. + continue; + } + + if (matches) { segments.add(new SegmentResult() .withName(segmentContext.getName()) .withMetadata(segmentContext.getMetadata())); @@ -83,26 +288,14 @@ private static SegmentEvaluationResult evaluateSegments( if (segmentOverrides != null) { for (FeatureContext featureContext : segmentOverrides) { String featureName = featureContext.getName(); + ImmutablePair incumbent = segmentFeatureContexts + .get(featureName); - if (segmentFeatureContexts.containsKey(featureName)) { - ImmutablePair existing = segmentFeatureContexts - .get(featureName); - FeatureContext existingFeatureContext = existing.getRight(); - - Double existingPriority = existingFeatureContext.getPriority() == null - ? EngineConstants.WEAKEST_PRIORITY - : existingFeatureContext.getPriority(); - Double featurePriority = featureContext.getPriority() == null - ? EngineConstants.WEAKEST_PRIORITY - : featureContext.getPriority(); - - if (existingPriority < featurePriority) { - continue; - } + if (winsOver(featureContext, incumbent == null ? null : incumbent.getRight())) { + segmentFeatureContexts.put(featureName, + new ImmutablePair( + segmentContext.getName(), featureContext)); } - segmentFeatureContexts.put(featureName, - new ImmutablePair( - segmentContext.getName(), featureContext)); } } } @@ -118,9 +311,7 @@ private static Flags evaluateFeatures( Features contextFeatures = context.getFeatures(); Flags flags = new Flags(); - String identityKey = context.getIdentity() != null - ? context.getIdentity().getKey() - : null; + String identityKey = getIdentityKey(context); if (contextFeatures != null) { for (FeatureContext featureContext : contextFeatures.getAdditionalProperties().values()) { @@ -144,45 +335,54 @@ private static Flags evaluateFeatures( return flags; } + private static String getIdentityKey(EvaluationContext context) { + return context.getIdentity() != null + ? context.getIdentity().getKey() + : null; + } + private static FlagResult getFlagResultFromFeatureContext( FeatureContext featureContext, String identityKey, String reason) { - if (identityKey != null) { - List variants = featureContext.getVariants(); - if (variants != null) { - Float percentageValue = Hashing.getInstance() - .getHashedPercentageForObjectIds(List.of(featureContext.getKey(), identityKey)); - - Float startPercentage = 0.0f; - - ArrayList sortedVariants = new ArrayList<>(variants); - sortedVariants.sort((a, b) -> { - Double priority = a.getPriority(); - Double comparedPriority = b.getPriority(); - return priority.compareTo(comparedPriority); - }); - for (FeatureValue variant : sortedVariants) { - Double weight = variant.getWeight(); - Float limit = startPercentage + weight.floatValue(); - if (startPercentage <= percentageValue && percentageValue < limit) { - return new FlagResult().withEnabled(featureContext.getEnabled()) - .withName(featureContext.getName()) - .withValue(variant.getValue()) - .withReason("SPLIT; weight=" + BigDecimal.valueOf(weight) - .stripTrailingZeros() - .toPlainString()) - .withMetadata(featureContext.getMetadata()); - } - startPercentage = limit; + Object value = featureContext.getValue(); + String variant = null; + + List variants = featureContext.getVariants(); + if (identityKey != null && variants != null && !variants.isEmpty()) { + variant = "control"; // A matched variant overrides the control bucket. + + Float percentageValue = Hashing.getInstance() + .getHashedPercentageForObjectIds(List.of(featureContext.getKey(), identityKey)); + + Float startPercentage = 0.0f; + + ArrayList sortedVariants = new ArrayList<>(variants); + sortedVariants.sort((a, b) -> { + Double priority = a.getPriority(); + Double comparedPriority = b.getPriority(); + return priority.compareTo(comparedPriority); + }); + for (FeatureValue featureValue : sortedVariants) { + Double weight = featureValue.getWeight(); + Float limit = startPercentage + weight.floatValue(); + if (startPercentage <= percentageValue && percentageValue < limit) { + value = featureValue.getValue(); + variant = featureValue.getKey(); + reason = "SPLIT; weight=" + BigDecimal.valueOf(weight) + .stripTrailingZeros() + .toPlainString(); + break; } + startPercentage = limit; } } return new FlagResult().withEnabled(featureContext.getEnabled()) .withName(featureContext.getName()) - .withValue(featureContext.getValue()) + .withValue(value) .withReason(reason) + .withVariant(variant) .withMetadata(featureContext.getMetadata()); } -} \ No newline at end of file +} diff --git a/src/main/java/com/flagsmith/flagengine/segments/SegmentEvaluator.java b/src/main/java/com/flagsmith/flagengine/segments/SegmentEvaluator.java index cbfb741..17894f9 100644 --- a/src/main/java/com/flagsmith/flagengine/segments/SegmentEvaluator.java +++ b/src/main/java/com/flagsmith/flagengine/segments/SegmentEvaluator.java @@ -31,6 +31,9 @@ public class SegmentEvaluator { .setOptions(Option.SUPPRESS_EXCEPTIONS); private static TypeReference> stringListTypeRef = new TypeReference>() { }; + private static TypeReference> contextMapTypeRef = + new TypeReference>() { + }; /** * Check if context is in segment. @@ -40,15 +43,28 @@ public class SegmentEvaluator { * @return true if context is in segment. */ public static Boolean isContextInSegment(EvaluationContext context, SegmentContext segment) { + return isContextInSegment(context, segment, Map.of()); + } + + /** + * Check if context is in segment, with conditions reading `$.flags` from the given mapping. + * + * @param context Evaluation context. + * @param segment Segment context. + * @param flags Flag results by feature name, as serialised maps. + * @return true if context is in segment. + */ + public static Boolean isContextInSegment(EvaluationContext context, SegmentContext segment, + Map flags) { List rules = segment.getRules(); return !rules.isEmpty() && rules.stream() - .allMatch((rule) -> contextMatchesRule(context, rule, segment.getKey())); + .allMatch((rule) -> contextMatchesRule(context, rule, segment.getKey(), flags)); } private static Boolean contextMatchesRule(EvaluationContext context, SegmentRule rule, - String segmentKey) { + String segmentKey, Map flags) { Predicate conditionPredicate = (condition) -> contextMatchesCondition( - context, condition, segmentKey); + context, condition, segmentKey, flags); Boolean isMatch; List conditions = rule.getConditions(); @@ -81,7 +97,7 @@ private static Boolean contextMatchesRule(EvaluationContext context, SegmentRule } Predicate subRulePredicate = (subRule) -> contextMatchesRule( - context, subRule, segmentKey); + context, subRule, segmentKey, flags); switch (rule.getType()) { case ALL: return subRules.stream().allMatch(subRulePredicate); @@ -97,7 +113,8 @@ private static Boolean contextMatchesRule(EvaluationContext context, SegmentRule private static Boolean contextMatchesCondition( EvaluationContext context, SegmentCondition condition, - String segmentKey) { + String segmentKey, + Map flags) { Object contextValue = null; Object conditionValue = condition.getValue(); String conditionProperty = condition.getProperty(); @@ -114,7 +131,7 @@ private static Boolean contextMatchesCondition( contextValue = identity.getKey(); } } else { - contextValue = getContextValue(context, conditionProperty); + contextValue = getContextValue(context, conditionProperty, flags); } switch (operator) { @@ -216,9 +233,11 @@ private static Boolean contextMatchesCondition( * * @param context Evaluation context. * @param property Property name. + * @param flags Flag results by feature name, as serialised maps. * @return Property value. */ - private static Object getContextValue(EvaluationContext context, String property) { + private static Object getContextValue(EvaluationContext context, String property, + Map flags) { Object result; if (context.getIdentity() != null && context.getIdentity().getTraits() != null) { result = context.getIdentity().getTraits().getAdditionalProperties().get(property); @@ -227,9 +246,16 @@ private static Object getContextValue(EvaluationContext context, String property } } if (property.startsWith("$.")) { + Map contextMap; + if (property.startsWith("$.flags.") || property.startsWith("$.flags[")) { + contextMap = Map.of("flags", flags); // Spares serialising a context the path can't reach. + } else { + contextMap = mapper.convertValue(context, contextMapTypeRef); + contextMap.put("flags", flags); // Replaces any client-supplied flags. + } result = JsonPath .using(jsonPathConfiguration) - .parse(mapper.convertValue(context, Map.class)) + .parse(contextMap) .read(property); if (result instanceof List || result instanceof Map) { return null; diff --git a/src/test/java/com/flagsmith/flagengine/enginetestdata b/src/test/java/com/flagsmith/flagengine/enginetestdata index 4b29dc7..28363b3 160000 --- a/src/test/java/com/flagsmith/flagengine/enginetestdata +++ b/src/test/java/com/flagsmith/flagengine/enginetestdata @@ -1 +1 @@ -Subproject commit 4b29dc772a764364af2dd504ecefbdf74cf5473f +Subproject commit 28363b388de2850c7c3f1b6e7a8bdd41d8da433c