Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[submodule "tests/Engine/EngineTests/EngineTestData"]
path = tests/Engine/EngineTests/EngineTestData
url = git@github.com:Flagsmith/engine-test-data.git
branch = v3.7.0
branch = v3.11.0
81 changes: 70 additions & 11 deletions src/Engine/Engine.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Flagsmith\Engine;

use Flagsmith\Engine\Utils\Hashing;
use Flagsmith\Engine\Utils\LazyFlags;
use Flagsmith\Engine\Utils\Semver;
use Flagsmith\Engine\Utils\Types\Context\EvaluationContext;
use Flagsmith\Engine\Utils\Types\Context\FeatureContext;
Expand Down Expand Up @@ -46,8 +47,28 @@ public static function getEvaluationResult($context): EvaluationResult

$context = self::getEnrichedContext($context);

// Discard any caller-supplied `$.flags`: they are resolved lazily as
// segment conditions read them. Without segments, nothing reads them.
$resolvedFlags = null;
if (!empty($context->segments)) {
$context = clone $context;
$resolvedFlags = new LazyFlags($context);
$context->flags = $resolvedFlags;
}

$cycleHits = 0;

foreach ($context->segments as $segment) {
if (!self::isContextInSegment($context, $segment)) {
$matches = self::isContextInSegment($context, $segment);

if ($resolvedFlags->getCycleHits() !== $cycleHits) {
// Evaluating this segment cut a cycle, so it matched, or failed to,
// on a flag that could not be resolved. Neither verdict is sound.
$cycleHits = $resolvedFlags->getCycleHits();
continue;
}

if (!$matches) {
continue;
}

Expand All @@ -58,14 +79,8 @@ public static function getEvaluationResult($context): EvaluationResult

foreach ($segment->overrides as $overrideFeature) {
$featureName = $overrideFeature->name;
$evaluatedFeature = $evaluatedFeatures[$featureName] ?? null;
if ($evaluatedFeature) {
$overrideWinsPriority =
($overrideFeature->priority ?? self::WEAKEST_PRIORITY) <
($evaluatedFeature->priority ?? self::WEAKEST_PRIORITY);
if (!$overrideWinsPriority) {
continue;
}
if (!self::overrideWins($overrideFeature, $evaluatedFeatures[$featureName] ?? null)) {
continue;
}

$evaluatedFeatures[$featureName] = $overrideFeature;
Expand All @@ -89,6 +104,24 @@ public static function getEvaluationResult($context): EvaluationResult
);
}

if ($resolvedFlags !== null) {
$context->flags = null; // Frees both by reference counting, not by the cycle collector
}

if ($resolvedFlags?->isUsed()) {
// Only reached when a segment condition read a flag. Those results take
// precedence: they were resolved with the cycle guard, unlike the
// single-pass recomputation above.
foreach ($resolvedFlags->getResolved() as $featureName => $flag) {
$evaluatedFlags[$featureName] = $flag;
}
foreach ($resolvedFlags->getCyclic() as $featureName) {
if (isset($evaluatedFlags[$featureName])) {
$evaluatedFlags[$featureName]->reason = LazyFlags::CIRCULAR_DEPENDENCY_REASON;
}
}
}

$result = new EvaluationResult();
$result->flags = $evaluatedFlags;
$result->segments = $evaluatedSegments;
Expand All @@ -114,11 +147,29 @@ private static function getEnrichedContext(EvaluationContext $context): Evaluati
}

/**
* 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 happen to be
* evaluated in.
* @internal
* @param FeatureContext $candidate
* @param ?FeatureContext $incumbent
* @return bool
*/
public static function overrideWins($candidate, $incumbent): bool
{
return $incumbent === null
|| ($candidate->priority ?? self::WEAKEST_PRIORITY) < ($incumbent->priority ?? self::WEAKEST_PRIORITY);
}

/**
* @internal
* @param EvaluationContext $context
* @param SegmentContext $segment
* @return bool
*/
private static function isContextInSegment($context, $segment): bool
public static function isContextInSegment($context, $segment): bool
{
if (empty($segment->rules)) {
return false;
Expand All @@ -134,14 +185,20 @@ private static function isContextInSegment($context, $segment): bool
}

/**
* @internal
* @param FeatureContext $feature
* @param ?string $splitKey
* @param string $reason
* @return FlagResult
*/
private static function getFlagResultFromFeatureContext($feature, $splitKey, $reason = 'DEFAULT')
public static function getFlagResultFromFeatureContext($feature, $splitKey, $reason = 'DEFAULT')
{
$variantKey = null;

if ($splitKey !== null && !empty($feature->variants)) {
// Default to the control bucket; a matched named variant overrides this.
$variantKey = 'control';

$hashing = new Hashing();
$percentageValue = $hashing->getHashedPercentageForObjectIds([
$feature->key,
Expand All @@ -163,6 +220,7 @@ private static function getFlagResultFromFeatureContext($feature, $splitKey, $re
$flag->name = $feature->name;
$flag->enabled = $feature->enabled;
$flag->value = $variant->value;
$flag->variant = $variant->key;
$flag->reason = "SPLIT; weight={$variant->weight}";
$flag->metadata = $feature->metadata;
return $flag;
Expand All @@ -175,6 +233,7 @@ private static function getFlagResultFromFeatureContext($feature, $splitKey, $re
$flag->name = $feature->name;
$flag->enabled = $feature->enabled;
$flag->value = $feature->value;
$flag->variant = $variantKey;
$flag->reason = $reason;
$flag->metadata = $feature->metadata;
return $flag;
Expand Down
214 changes: 214 additions & 0 deletions src/Engine/Utils/LazyFlags.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
<?php

namespace Flagsmith\Engine\Utils;

use Flagsmith\Engine\Engine;
use Flagsmith\Engine\Utils\Types\Context\EvaluationContext;
use Flagsmith\Engine\Utils\Types\Context\FeatureContext;
use Flagsmith\Engine\Utils\Types\Context\SegmentContext;
use Flagsmith\Engine\Utils\Types\Result\FlagResult;

/**
* The `$.flags` mapping, resolving a flag when a condition first reads it.
*
* State is kept private so that it cannot be reached by a JSONPath query.
*
* @internal
* @implements \ArrayAccess<string, FlagResult>
*/
final class LazyFlags implements \ArrayAccess
{
public const CIRCULAR_DEPENDENCY_REASON = 'ERROR; code=CIRCULAR_DEPENDENCY';

/** @var array<string, FlagResult> */
private array $flags = [];

/**
* Names of the flags found to be in a dependency cycle.
* @var array<string, true>
*/
private array $cyclic = [];

/**
* Set the first time a condition reads a flag. Distinguishes "no
* dependency was ever consulted" from "one was, and resolved to nothing",
* which an empty mapping cannot.
*/
private bool $used = false;

/**
* Incremented whenever a cycle is cut. A segment whose own evaluation
* increments it read a flag that could not be resolved, so its verdict
* rests on that flag's absence and it must not match.
*/
private int $cycleHits = 0;

/**
* Stack of the features currently being resolved.
* @var array<string>
*/
private array $resolving = [];

/** @var array<string, bool> */
private array $segmentMatches = [];

/** @var ?array<string, array<string>> */
private ?array $segmentKeysByFeatureName = null;

public function __construct(private EvaluationContext $context)
{
}

public function isUsed(): bool
{
return $this->used;
}

public function getCycleHits(): int
{
return $this->cycleHits;
}

/** @return array<string, FlagResult> */
public function getResolved(): array
{
return $this->flags;
}

/** @return array<string> */
public function getCyclic(): array
{
return array_keys($this->cyclic);
}

public function offsetExists(mixed $offset): bool
{
return $this->offsetGet($offset) !== null;
}

public function offsetGet(mixed $offset): ?FlagResult
{
$featureName = (string) $offset;
if (!array_key_exists($featureName, $this->flags)) {
$this->used = true;
$this->resolveFeature($featureName);
}
return $this->flags[$featureName] ?? null;
}

public function offsetSet(mixed $offset, mixed $value): void
{
throw new \LogicException('$.flags is read-only');
}

public function offsetUnset(mixed $offset): void
{
throw new \LogicException('$.flags is read-only');
}

/**
* Resolve the flag for a feature, applying any segment override.
* @param string $featureName
* @return void
*/
private function resolveFeature(string $featureName): void
{
$cycleStart = array_search($featureName, $this->resolving, strict: true);
if ($cycleStart !== false) {
// Cyclic dependency. Leave the flag unresolved.
foreach (array_slice($this->resolving, $cycleStart) as $cyclicFeatureName) {
$this->cyclic[$cyclicFeatureName] = true;
}
$this->cycleHits++;
return;
}

$feature = $this->context->features[$featureName] ?? null;
if ($feature === null) {
// Depending on a feature absent from the context.
return;
}

$this->resolving[] = $featureName;
try {
[$overrideFeature, $overrideSegment] = $this->getSegmentOverride($featureName);
} finally {
array_pop($this->resolving);
}

if (isset($this->cyclic[$featureName])) {
// The result is not something another condition may match on.
return;
}

$this->flags[$featureName] = $overrideFeature !== null
? Engine::getFlagResultFromFeatureContext(
$overrideFeature,
$this->context->identity?->key,
"TARGETING_MATCH; segment={$overrideSegment->name}",
)
: Engine::getFlagResultFromFeatureContext(
$feature,
$this->context->identity?->key,
);
}

/**
* @param string $segmentKey
* @return bool
*/
private function matchesSegment(string $segmentKey): bool
{
if (array_key_exists($segmentKey, $this->segmentMatches)) {
return $this->segmentMatches[$segmentKey];
}

$cycleHits = $this->cycleHits;
$matches = Engine::isContextInSegment($this->context, $this->context->segments[$segmentKey]);

if ($this->cycleHits !== $cycleHits) {
// Reached by cutting a cycle, so the verdict rests on a flag that
// could not be resolved. Not a match, and not worth memoising.
return false;
}

$this->segmentMatches[$segmentKey] = $matches;
return $matches;
}

/**
* @param string $featureName
* @return array{?FeatureContext, ?SegmentContext} The winning override and its segment
*/
private function getSegmentOverride(string $featureName): array
{
if ($this->segmentKeysByFeatureName === null) {
$this->segmentKeysByFeatureName = [];
foreach ($this->context->segments as $segmentKey => $segment) {
foreach ($segment->overrides as $override) {
$this->segmentKeysByFeatureName[$override->name][] = $segmentKey;
}
}
}

$overrideFeature = null;
$overrideSegment = null;
foreach ($this->segmentKeysByFeatureName[$featureName] ?? [] as $segmentKey) {
if (!$this->matchesSegment($segmentKey)) {
continue;
}
$segment = $this->context->segments[$segmentKey];
foreach ($segment->overrides as $override) {
if ($override->name !== $featureName) {
continue;
}
if (Engine::overrideWins($override, $overrideFeature)) {
$overrideFeature = $override;
$overrideSegment = $segment;
}
}
}

return [$overrideFeature, $overrideSegment];
}
}
Loading
Loading