diff --git a/.github/actions/ci/action.yml b/.github/actions/ci/action.yml index 65fe8927..8202bdad 100644 --- a/.github/actions/ci/action.yml +++ b/.github/actions/ci/action.yml @@ -55,4 +55,4 @@ runs: with: test_service_port: 8000 token: ${{ inputs.token }} - extra_params: "-skip 'evaluation/parameterized/attribute references/array index is not supported'" + extra_params: "-skip 'evaluation/parameterized/attribute references/array index is not supported' -skip 'big segments/membership caching/context cache eviction (cache size)'" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14c2614b..45a4ae16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,7 @@ jobs: - 8080:8080 strategy: + fail-fast: false matrix: php-version: [8.1, 8.2] use-lowest-dependencies: [true, false] diff --git a/Makefile b/Makefile index b284dfe1..bcd03f16 100644 --- a/Makefile +++ b/Makefile @@ -13,8 +13,14 @@ TEMP_TEST_OUTPUT=/tmp/sse-contract-test-service.log # - "evaluation/parameterized/attribute references/array index is not supported": Due to how PHP # arrays work, there's no way to disallow an array index lookup without breaking object property # lookups for properties that are numeric strings. +# +# - "big segments/membership caching/context cache eviction (cache size)": Caching is provided through +# PSR-6 (psr/cache) interface. This interface does not provide a way to limit the cache size. The +# test harness expects the cache to evict items when the cache size is exceeded. This is not possible +# with the current implementation. TEST_HARNESS_PARAMS := $(TEST_HARNESS_PARAMS) \ - -skip 'evaluation/parameterized/attribute references/array index is not supported' + -skip 'evaluation/parameterized/attribute references/array index is not supported' \ + -skip 'big segments/membership caching/context cache eviction (cache size)' build-contract-tests: @cd test-service && composer install --no-progress diff --git a/composer.json b/composer.json index 19dbc8b4..50fbe1cc 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "require": { "php": ">=8.1", "monolog/monolog": "^2.0|^3.0", + "psr/cache": "^3.0", "psr/log": "^1.0|^2.0|^3.0" }, "require-dev": { diff --git a/src/LaunchDarkly/BigSegmentsEvaluationStatus.php b/src/LaunchDarkly/BigSegmentsEvaluationStatus.php new file mode 100644 index 00000000..44275e13 --- /dev/null +++ b/src/LaunchDarkly/BigSegmentsEvaluationStatus.php @@ -0,0 +1,36 @@ +_kind = $kind; $this->_errorKind = $errorKind; @@ -172,6 +174,28 @@ private function __construct( $this->_ruleId = $ruleId; $this->_prerequisiteKey = $prerequisiteKey; $this->_inExperiment = $inExperiment; + $this->_bigSegmentsEvaluationStatus = $bigSegmentsEvaluationStatus; + } + + /** + * Returns a new EvaluationReason instance matching all the properties of + * this one, except for the big segments evaluation status. + */ + public function withBigSegmentsEvaluationStatus(BigSegmentsEvaluationStatus $bigSegmentsEvaluationStatus): EvaluationReason + { + if ($this->_bigSegmentsEvaluationStatus == $bigSegmentsEvaluationStatus) { + return $this; + } + + return new EvaluationReason( + $this->_kind, + $this->_errorKind, + $this->_ruleIndex, + $this->_ruleId, + $this->_prerequisiteKey, + $this->_inExperiment, + $bigSegmentsEvaluationStatus + ); } /** @@ -233,6 +257,21 @@ public function isInExperiment(): bool return $this->_inExperiment; } + /** + * Describes the validity of Big Segment information, if and only if the + * flag evaluation required querying at least one Big Segment. Otherwise it + * returns null. Possible values are defined by {@see + * BigSegmentsEvaluationStatus}. + * + * Big Segments are a specific kind of context segments. For more + * information, read the LaunchDarkly documentation: + * https://docs.launchdarkly.com/home/users/big-segments + */ + public function bigSegmentsEvaluationStatus(): ?BigSegmentsEvaluationStatus + { + return $this->_bigSegmentsEvaluationStatus; + } + /** * Returns a simple string representation of this object. */ @@ -272,6 +311,9 @@ public function jsonSerialize(): array if ($this->_inExperiment) { $ret['inExperiment'] = $this->_inExperiment; } + if ($this->_bigSegmentsEvaluationStatus !== null) { + $ret['bigSegmentsStatus'] = $this->_bigSegmentsEvaluationStatus->value; + } return $ret; } } diff --git a/src/LaunchDarkly/Impl/BigSegments/MembershipResult.php b/src/LaunchDarkly/Impl/BigSegments/MembershipResult.php new file mode 100644 index 00000000..d40be1c1 --- /dev/null +++ b/src/LaunchDarkly/Impl/BigSegments/MembershipResult.php @@ -0,0 +1,19 @@ + $membership + */ + public function __construct( + public readonly ?array $membership, + public readonly BigSegmentsEvaluationStatus $status + ) { + } +} diff --git a/src/LaunchDarkly/Impl/BigSegments/StoreManager.php b/src/LaunchDarkly/Impl/BigSegments/StoreManager.php new file mode 100644 index 00000000..1fd1244c --- /dev/null +++ b/src/LaunchDarkly/Impl/BigSegments/StoreManager.php @@ -0,0 +1,111 @@ +config = $config; + $this->store = $config->store; + $this->statusProvider = new Impl\BigSegments\StoreStatusProvider( + fn () => $this->pollAndUpdateStatus(), + $logger + ); + $this->lastStatus = null; + $this->lastStatusPollTime = null; + } + + public function getStatusProvider(): Subsystems\BigSegmentStatusProvider + { + return $this->statusProvider; + } + + public function getContextMembership(string $contextKey): ?Impl\BigSegments\MembershipResult + { + if ($this->store === null) { + return null; + } + + $cachedItem = null; + try { + $cachedItem = $this->config->cache?->getItem($contextKey); + } catch (Exception $e) { + $this->logger->warning("Failed to retrieve cached item for big segment", ['contextKey' => $contextKey, 'exception' => $e->getMessage()]); + } + /** @var ?array */ + $membership = $cachedItem?->get(); + + if ($membership === null) { + try { + $membership = $this->store->getMembership(StoreManager::hashForContextKey($contextKey)); + if ($this->config->cache !== null && $cachedItem !== null) { + $cachedItem->set($membership)->expiresAfter($this->config->contextCacheTime); + + if (!$this->config->cache->save($cachedItem)) { + $this->logger->warning("Failed to save Big Segment membership to cache", ['contextKey' => $contextKey]); + } + } + } catch (Exception $e) { + $this->logger->warning("Failed to retrieve Big Segment membership", ['contextKey' => $contextKey, 'exception' => $e->getMessage()]); + return new Impl\BigSegments\MembershipResult(null, BigSegmentsEvaluationStatus::STORE_ERROR); + } + } + + $nextPollingTime = ($this->lastStatusPollTime?->getTimestamp() ?? 0) + $this->config->statusPollInterval; + + $status = $this->lastStatus; + if ($this->lastStatusPollTime === null || $nextPollingTime < time()) { + $status = $this->pollAndUpdateStatus(); + } + + if ($status === null || !$status->isAvailable()) { + return new Impl\BigSegments\MembershipResult($membership, BigSegmentsEvaluationStatus::STORE_ERROR); + } + + return new Impl\BigSegments\MembershipResult($membership, $status->isStale() ? BigSegmentsEvaluationStatus::STALE : BigSegmentsEvaluationStatus::HEALTHY); + } + + private function pollAndUpdateStatus(): Types\BigSegmentsStoreStatus + { + $newStatus = new Types\BigSegmentsStoreStatus(false, false); + if ($this->store !== null) { + try { + $metadata = $this->store->getMetadata(); + $newStatus = new Types\BigSegmentsStoreStatus( + available: true, + stale: $metadata->isStale($this->config->staleAfter) + ); + } catch (Exception $e) { + $this->logger->warning("Failed to retrieve Big Segment metadata", ['exception' => $e->getMessage()]); + } + } + + $this->lastStatus = $newStatus; + $this->statusProvider->updateStatus($newStatus); + $this->lastStatusPollTime = new DateTimeImmutable(); + + return $newStatus; + } + + private static function hashForContextKey(string $contextKey): string + { + return base64_encode(hash('sha256', $contextKey, true)); + } +} diff --git a/src/LaunchDarkly/Impl/BigSegments/StoreStatusProvider.php b/src/LaunchDarkly/Impl/BigSegments/StoreStatusProvider.php new file mode 100644 index 00000000..f4ae7932 --- /dev/null +++ b/src/LaunchDarkly/Impl/BigSegments/StoreStatusProvider.php @@ -0,0 +1,78 @@ +listeners = new SplObjectStorage(); + $this->statusFn = $statusFn; + $this->lastStatus = null; + $this->logger = $logger; + } + + public function attach(Subsystems\BigSegmentStatusListener $listener): void + { + $this->listeners->attach($listener); + } + + public function detach(Subsystems\BigSegmentStatusListener $listener): void + { + $this->listeners->detach($listener); + } + + /** + * @internal + */ + public function updateStatus(Types\BigSegmentsStoreStatus $status): void + { + if ($this->lastStatus != $status) { + $old = $this->lastStatus; + $this->lastStatus = $status; + + $this->notify(old: $old, new: $status); + } + } + + private function notify(?Types\BigSegmentsStoreStatus $old, Types\BigSegmentsStoreStatus $new): void + { + /** @var Subsystems\BigSegmentStatusListener $listener */ + foreach ($this->listeners as $listener) { + try { + $listener->statusChanged($old, $new); + } catch (Exception $e) { + $this->logger->warning('A big segments status listener threw an exception', ['exception' => $e->getMessage()]); + } + } + } + + public function lastStatus(): ?Types\BigSegmentsStoreStatus + { + return $this->lastStatus; + } + + public function status(): Types\BigSegmentsStoreStatus + { + return ($this->statusFn)(); + } +} diff --git a/src/LaunchDarkly/Impl/Evaluation/EvalResult.php b/src/LaunchDarkly/Impl/Evaluation/EvalResult.php index 4abcb2e7..71ab1ebe 100644 --- a/src/LaunchDarkly/Impl/Evaluation/EvalResult.php +++ b/src/LaunchDarkly/Impl/Evaluation/EvalResult.php @@ -34,6 +34,11 @@ public function withState(EvaluatorState $state): EvalResult return new EvalResult($this->_detail, $this->_forceReasonTracking, $state); } + public function withDetail(EvaluationDetail $detail): EvalResult + { + return new EvalResult($detail, $this->_forceReasonTracking, $this->_state); + } + public function getDetail(): EvaluationDetail { return $this->_detail; diff --git a/src/LaunchDarkly/Impl/Evaluation/Evaluator.php b/src/LaunchDarkly/Impl/Evaluation/Evaluator.php index f0e69867..c78e6860 100644 --- a/src/LaunchDarkly/Impl/Evaluation/Evaluator.php +++ b/src/LaunchDarkly/Impl/Evaluation/Evaluator.php @@ -4,8 +4,10 @@ namespace LaunchDarkly\Impl\Evaluation; +use LaunchDarkly\BigSegmentsEvaluationStatus; use LaunchDarkly\EvaluationDetail; use LaunchDarkly\EvaluationReason; +use LaunchDarkly\Impl\BigSegments; use LaunchDarkly\Impl\Model\Clause; use LaunchDarkly\Impl\Model\FeatureFlag; use LaunchDarkly\Impl\Model\Rule; @@ -27,11 +29,13 @@ class Evaluator { private FeatureRequester $_featureRequester; + private BigSegments\StoreManager $_bigSegmentsStoreManager; private LoggerInterface $_logger; - public function __construct(FeatureRequester $featureRequester, ?LoggerInterface $logger = null) + public function __construct(FeatureRequester $featureRequester, BigSegments\StoreManager $bigSegmentsStoreManager, ?LoggerInterface $logger = null) { $this->_featureRequester = $featureRequester; + $this->_bigSegmentsStoreManager = $bigSegmentsStoreManager; $this->_logger = $logger ?: Util::makeNullLogger(); } @@ -50,8 +54,21 @@ public function evaluate(FeatureFlag $flag, LDContext $context, ?callable $prere { $state = new EvaluatorState($flag); try { - return $this->evaluateInternal($flag, $context, $prereqEvalSink, $state) + $evalResult = $this->evaluateInternal($flag, $context, $prereqEvalSink, $state) ->withState($state); + + if ($state->bigSegmentsEvaluationStatus !== null) { + $reason = $evalResult->getDetail()->getReason()->withBigSegmentsEvaluationStatus($state->bigSegmentsEvaluationStatus); + $detail = new EvaluationDetail( + $evalResult->getDetail()->getValue(), + $evalResult->getDetail()->getVariationIndex(), + $reason + ); + + $evalResult = $evalResult->withDetail($detail); + } + + return $evalResult; } catch (EvaluationException $e) { return new EvalResult(new EvaluationDetail(null, null, EvaluationReason::error($e->getErrorKind())), false, $state); } catch (\Throwable $e) { @@ -248,22 +265,34 @@ private function clauseMatchesContext(Clause $clause, LDContext $context, Evalua private function segmentMatchesContext(Segment $segment, LDContext $context, EvaluatorState $state): bool { - if (EvaluatorHelpers::contextKeyIsInTargetList($context, null, $segment->getIncluded())) { - return true; + if ($segment->getUnbounded()) { + return $this->bigSegmentsContextMatch($segment, $context, $state); } - foreach ($segment->getIncludedContexts() as $t) { - if (EvaluatorHelpers::contextKeyIsInTargetList($context, $t->getContextKind(), $t->getValues())) { + + return $this->simpleSegmentContextMatch($segment, $context, $state, true); + } + + private function simpleSegmentContextMatch(Segment $segment, LDContext $context, EvaluatorState $state, bool $useIncludes): bool + { + if ($useIncludes) { + if (EvaluatorHelpers::contextKeyIsInTargetList($context, null, $segment->getIncluded())) { return true; } - } - if (EvaluatorHelpers::contextKeyIsInTargetList($context, null, $segment->getExcluded())) { - return false; - } - foreach ($segment->getExcludedContexts() as $t) { - if (EvaluatorHelpers::contextKeyIsInTargetList($context, $t->getContextKind(), $t->getValues())) { + foreach ($segment->getIncludedContexts() as $t) { + if (EvaluatorHelpers::contextKeyIsInTargetList($context, $t->getContextKind(), $t->getValues())) { + return true; + } + } + if (EvaluatorHelpers::contextKeyIsInTargetList($context, null, $segment->getExcluded())) { return false; } + foreach ($segment->getExcludedContexts() as $t) { + if (EvaluatorHelpers::contextKeyIsInTargetList($context, $t->getContextKind(), $t->getValues())) { + return false; + } + } } + $rules = $segment->getRules(); if (count($rules) !== 0) { // Evaluating rules means we might be doing recursive segment matches, so we'll push the current @@ -285,6 +314,68 @@ private function segmentMatchesContext(Segment $segment, LDContext $context, Eva return false; } + private function bigSegmentsContextMatch(Segment $segment, LDContext $context, EvaluatorState $state): bool + { + if ($segment->getGeneration() === null) { + $state->bigSegmentsEvaluationStatus = BigSegmentsEvaluationStatus::NOT_CONFIGURED; + return false; + } + + $matchedContext = $context->getIndividualContext($segment->getUnboundedContextKind()); + if ($matchedContext === null) { + return false; + } + + /** @var ?array */ + $membership = null; + if ($state->bigSegmentsMembership !== null) { + $membership = $state->bigSegmentsMembership[$matchedContext->getKey()] ?? null; + } + + if ($membership === null) { + // Note that this query is just by key; the context kind doesn't + // matter because any given Big Segment can only reference one + // context kind. So if segment A for the "user" kind includes a + // "user" context with key X, and segment B for the "org" kind + // includes an "org" context with the same key X, it is fine to say + // that the membership for key X is segment A and segment B-- there + // is no ambiguity. + $result = $this->_bigSegmentsStoreManager->getContextMembership($matchedContext->getKey()); + if ($result !== null) { + $state->bigSegmentsEvaluationStatus = $result->status; + + $membership = $result->membership; + if ($state->bigSegmentsMembership === null) { + $state->bigSegmentsMembership = []; + } + $state->bigSegmentsMembership[$matchedContext->getKey()] = $membership; + } else { + $state->bigSegmentsEvaluationStatus = BigSegmentsEvaluationStatus::NOT_CONFIGURED; + } + } + + $membershipResult = null; + if ($membership !== null) { + $segmentRef = Evaluator::makeBigSegmentsRef($segment); + $membershipResult = $membership[$segmentRef] ?? false; + } + + if ($membershipResult !== null) { + return $membershipResult; + } + + return $this->simpleSegmentContextMatch($segment, $context, $state, false); + } + + private static function makeBigSegmentsRef(Segment $segment): string + { + // The format of Big Segment references is independent of what store + // implementation is being used; the store implementation receives only + // this string and does not know the details of the data model. The + // Relay Proxy will use the same format when writing to the store. + return sprintf("%s.g%s", $segment->getKey(), $segment->getGeneration() ?? ''); + } + private function segmentRuleMatchesContext( SegmentRule $rule, LDContext $context, @@ -292,7 +383,6 @@ private function segmentRuleMatchesContext( string $segmentSalt, EvaluatorState $state ): bool { - $rulej = print_r($rule, true); foreach ($rule->getClauses() as $clause) { if (!$this->clauseMatchesContext($clause, $context, $state)) { return false; diff --git a/src/LaunchDarkly/Impl/Evaluation/EvaluatorState.php b/src/LaunchDarkly/Impl/Evaluation/EvaluatorState.php index c5b157f5..cd9a584e 100644 --- a/src/LaunchDarkly/Impl/Evaluation/EvaluatorState.php +++ b/src/LaunchDarkly/Impl/Evaluation/EvaluatorState.php @@ -4,6 +4,7 @@ namespace LaunchDarkly\Impl\Evaluation; +use LaunchDarkly\BigSegmentsEvaluationStatus; use LaunchDarkly\Impl\Model\FeatureFlag; /** @@ -16,6 +17,18 @@ class EvaluatorState public ?array $segmentStack = null; public ?array $prerequisites = null; public int $depth = 0; + public ?BigSegmentsEvaluationStatus $bigSegmentsEvaluationStatus = null; + + /** + * An associative array, indexed by an LDContext's key. Each value is a + * boolean indicating whether the user is a member of the corresponding + * segment. + * + * If the value is null, no big segments was referenced for this evaluation. + * + * @var ?array> + */ + public ?array $bigSegmentsMembership = null; public function __construct(public FeatureFlag $originalFlag) { diff --git a/src/LaunchDarkly/Impl/Model/Segment.php b/src/LaunchDarkly/Impl/Model/Segment.php index b22f9896..688e1aac 100644 --- a/src/LaunchDarkly/Impl/Model/Segment.php +++ b/src/LaunchDarkly/Impl/Model/Segment.php @@ -4,6 +4,8 @@ namespace LaunchDarkly\Impl\Model; +use LaunchDarkly\LDContext; + /** * Internal data model class that describes a user segment. * @@ -24,6 +26,9 @@ class Segment protected array $_includedContexts; /** @var SegmentTarget[] */ protected array $_excludedContexts; + protected bool $_unbounded; + protected string $_unboundedContextKind; + protected ?int $_generation; protected string $_salt; /** @var SegmentRule[] */ protected array $_rules = []; @@ -36,6 +41,9 @@ public function __construct( array $excluded, array $includedContexts, array $excludedContexts, + bool $unbounded, + ?string $unboundedContextKind, + ?int $generation, string $salt, array $rules, bool $deleted @@ -46,6 +54,9 @@ public function __construct( $this->_excluded = $excluded; $this->_includedContexts = $includedContexts; $this->_excludedContexts = $excludedContexts; + $this->_unbounded = $unbounded; + $this->_unboundedContextKind = $unboundedContextKind ?? LDContext::DEFAULT_KIND; + $this->_generation = $generation; $this->_salt = $salt; $this->_rules = $rules; $this->_deleted = $deleted; @@ -61,6 +72,9 @@ public static function getDecoder(): \Closure $v['excluded'] ?: [], array_map(SegmentTarget::getDecoder(), $v['includedContexts'] ?? []), array_map(SegmentTarget::getDecoder(), $v['excludedContexts'] ?? []), + $v['unbounded'] ?? false, + $v['unboundedContextKind'] ?? null, + $v['generation'] ?? null, $v['salt'], array_map(SegmentRule::getDecoder(), $v['rules'] ?: []), $v['deleted'] @@ -101,6 +115,21 @@ public function getIncludedContexts(): array return $this->_includedContexts; } + public function getUnbounded(): bool + { + return $this->_unbounded; + } + + public function getUnboundedContextKind(): string + { + return $this->_unboundedContextKind; + } + + public function getGeneration(): ?int + { + return $this->_generation; + } + public function getKey(): string { return $this->_key; diff --git a/src/LaunchDarkly/LDClient.php b/src/LaunchDarkly/LDClient.php index 897a77f3..0dcd419c 100644 --- a/src/LaunchDarkly/LDClient.php +++ b/src/LaunchDarkly/LDClient.php @@ -4,6 +4,7 @@ namespace LaunchDarkly; +use LaunchDarkly\Impl\BigSegments; use LaunchDarkly\Impl\Evaluation\EvalResult; use LaunchDarkly\Impl\Evaluation\Evaluator; use LaunchDarkly\Impl\Evaluation\PrerequisiteEvaluationRecord; @@ -17,8 +18,10 @@ use LaunchDarkly\Integrations\Guzzle; use LaunchDarkly\Migrations\OpTracker; use LaunchDarkly\Migrations\Stage; +use LaunchDarkly\Subsystems\BigSegmentStatusProvider; use LaunchDarkly\Subsystems\FeatureRequester; use LaunchDarkly\Types\ApplicationInfo; +use LaunchDarkly\Types\BigSegmentsConfig; use Monolog\Handler\ErrorLogHandler; use Monolog\Logger; use Psr\Log\LoggerInterface; @@ -50,6 +53,8 @@ class LDClient protected FeatureRequester $_featureRequester; protected EventFactory $_eventFactoryDefault; protected EventFactory $_eventFactoryWithReasons; + protected BigSegments\StoreManager $_bigSegmentsStoreManager; + protected BigSegmentStatusProvider $_bigSegmentStatusProvider; /** * Creates a new client instance that connects to LaunchDarkly. @@ -80,6 +85,7 @@ class LDClient * per-user basis in the LDContext builder. * - `wrapper_name`: For use by wrapper libraries to set an identifying name for the wrapper being used. This will be sent in User-Agent headers during requests to the LaunchDarkly servers to allow recording metrics on the usage of these wrapper libraries. * - `wrapper_version`: For use by wrapper libraries to report the version of the library in use. If `wrapper_name` is not set, this field will be ignored. Otherwise the version string will be included in the User-Agent headers along with the `wrapper_name` during requests to the LaunchDarkly servers. + * - `big_segments`: An option {@see \LaunchDarkly\Types\BigSegmentsConfig} instance. * - Other options may be available depending on any features you are using from the `LaunchDarkly\Integrations` namespace. * * @return LDClient @@ -135,6 +141,13 @@ public function __construct(string $sdkKey, array $options = []) } } + $bigSegmentsConfig = $options['big_segments'] ?? null; + if (!$bigSegmentsConfig instanceof BigSegmentsConfig) { + $bigSegmentsConfig = new BigSegmentsConfig(store: null); + } + $this->_bigSegmentsStoreManager = new BigSegments\StoreManager($bigSegmentsConfig, $this->_logger); + $this->_bigSegmentStatusProvider = $this->_bigSegmentsStoreManager->getStatusProvider(); + $this->_eventFactoryDefault = new EventFactory(false); $this->_eventFactoryWithReasons = new EventFactory(true); @@ -152,7 +165,7 @@ public function __construct(string $sdkKey, array $options = []) $this->_featureRequester = $this->getFeatureRequester($sdkKey, $options); - $this->_evaluator = new Evaluator($this->_featureRequester, $this->_logger); + $this->_evaluator = new Evaluator($this->_featureRequester, $this->_bigSegmentsStoreManager, $this->_logger); } public function getLogger(): LoggerInterface @@ -160,6 +173,18 @@ public function getLogger(): LoggerInterface return $this->_logger; } + /** + * Returns an interface for tracking the status of a Big Segment store. + * + * The {@see BigSegmentsStoreStatusProvider} has methods for checking whether + * the Big Segment store is (as far as the SDK knows) currently operational + * and tracking changes in this status. + */ + public function getBigSegmentStatusProvider(): BigSegmentStatusProvider + { + return $this->_bigSegmentStatusProvider; + } + /** * @param string $sdkKey * @param mixed[] $options @@ -490,7 +515,7 @@ public function allFlagsState(LDContext $context, array $options = []): FeatureF $preloadedRequester = new PreloadedFeatureRequester($this->_featureRequester, $flags); // This saves us from doing repeated queries for prerequisite flags during evaluation - $tempEvaluator = new Evaluator($preloadedRequester); + $tempEvaluator = new Evaluator($preloadedRequester, $this->_bigSegmentsStoreManager); $state = new FeatureFlagsState(true); $clientOnly = !!($options['clientSideOnly'] ?? false); diff --git a/src/LaunchDarkly/Subsystems/BigSegmentStatusListener.php b/src/LaunchDarkly/Subsystems/BigSegmentStatusListener.php new file mode 100644 index 00000000..4eae7a8c --- /dev/null +++ b/src/LaunchDarkly/Subsystems/BigSegmentStatusListener.php @@ -0,0 +1,25 @@ +|null A map from segment reference to inclusion status + */ + public function getMembership(string $contextHash): ?array; +} diff --git a/src/LaunchDarkly/Types/BigSegmentsConfig.php b/src/LaunchDarkly/Types/BigSegmentsConfig.php new file mode 100644 index 00000000..7f97a691 --- /dev/null +++ b/src/LaunchDarkly/Types/BigSegmentsConfig.php @@ -0,0 +1,58 @@ +statusPollInterval = $statusPollInterval === null || $statusPollInterval < 0 ? self::DEFAULT_STATUS_POLL_INTERVAL : $statusPollInterval; + $this->staleAfter = $staleAfter === null || $staleAfter < 0 ? self::DEFAULT_STALE_AFTER : $staleAfter; + } +} diff --git a/src/LaunchDarkly/Types/BigSegmentsStoreMetadata.php b/src/LaunchDarkly/Types/BigSegmentsStoreMetadata.php new file mode 100644 index 00000000..9eff5a9a --- /dev/null +++ b/src/LaunchDarkly/Types/BigSegmentsStoreMetadata.php @@ -0,0 +1,38 @@ +lastUpToDate; + } + + /** + * Returns true if the metadata is considered stale, based on the current + * time and the provided staleAfter seconds value. If the metadata has never + * been updated, it is considered stale. + */ + public function isStale(int $staleAfter): bool + { + if ($this->lastUpToDate === null) { + return true; + } + + return time() - $this->lastUpToDate >= $staleAfter; + } +} diff --git a/src/LaunchDarkly/Types/BigSegmentsStoreStatus.php b/src/LaunchDarkly/Types/BigSegmentsStoreStatus.php new file mode 100644 index 00000000..9b3b9183 --- /dev/null +++ b/src/LaunchDarkly/Types/BigSegmentsStoreStatus.php @@ -0,0 +1,52 @@ +available; + } + + /** + * True if the Big Segment store is available, but has not been updated + * within the amount of time specified by {@see + * LaunchDarkly\Types\BigSegmentsConfig::$staleAfter}. + * + * This may indicate that the LaunchDarkly Relay Proxy, which populates the + * store, has stopped running or has become unable to receive fresh data + * from LaunchDarkly. Any feature flag evaluations that reference a Big + * Segment will be using the last known data, which may be out of date. + * Also, the {@see LaunchDarkly\EvaluationReason} associated with those + * evaluations will have a `big_segments_status` of `STALE`. + */ + public function isStale(): bool + { + return $this->stale; + } +} diff --git a/test-service/BigSegmentsStoreGuzzle.php b/test-service/BigSegmentsStoreGuzzle.php new file mode 100644 index 00000000..d124138b --- /dev/null +++ b/test-service/BigSegmentsStoreGuzzle.php @@ -0,0 +1,48 @@ +client->request('POST', $this->uri . '/getMetadata'); + + /** @var array */ + $json = json_decode($response->getBody()->getContents(), associative: true); + + /** @var mixed|null */ + $lastUpToDate = $json['lastUpToDate'] ?? null; + if ($lastUpToDate !== null) { + $lastUpToDate = (int) $lastUpToDate; + } + + return new BigSegmentsStoreMetadata($lastUpToDate); + } + + /** + * @return array|null + */ + public function getMembership(string $contextHash): ?array + { + $response = $this->client->request('POST', $this->uri . '/getMembership', ['json' => ['contextHash' => $contextHash]]); + + $body = $response->getBody()->getContents(); + + /** @var array */ + $json = json_decode($body, associative: true); + + /** @var array|null */ + return $json['values'] ?? null; + } +} diff --git a/test-service/SdkClientEntity.php b/test-service/SdkClientEntity.php index 937b0181..7059f0aa 100644 --- a/test-service/SdkClientEntity.php +++ b/test-service/SdkClientEntity.php @@ -13,17 +13,19 @@ use LaunchDarkly\Migrations\MigratorBuilder; use LaunchDarkly\Migrations\Operation; use LaunchDarkly\Migrations\Stage; +use LaunchDarkly\Types\BigSegmentsConfig; use LaunchDarkly\Types\Result; use Monolog\Formatter\LineFormatter; use Monolog\Handler\StreamHandler; use Monolog\Logger; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; class SdkClientEntity { private LDClient $_client; private Logger $_logger; - public function __construct($params) + public function __construct($params, bool $resetBigSegmentsStore) { $tag = $params['tag']; @@ -35,10 +37,10 @@ public function __construct($params) $logger->pushHandler($stream); $this->_logger = $logger; - $this->_client = self::createSdkClient($params, $logger); + $this->_client = self::createSdkClient($params, $resetBigSegmentsStore, $logger); } - public static function createSdkClient($params, $logger): LDClient + public static function createSdkClient($params, bool $resetBigSegmentsStore, $logger): LDClient { $config = $params['configuration']; @@ -57,6 +59,40 @@ public static function createSdkClient($params, $logger): LDClient $options['all_attributes_private'] = $eventsConfig['allAttributesPrivate'] ?? false; $options['private_attribute_names'] = $eventsConfig['globalPrivateAttributes'] ?? null; + $bigSegments = $config['bigSegments'] ?? null; + if ($bigSegments) { + $store = new BigSegmentsStoreGuzzle(new Client(), $bigSegments['callbackUri']); + + $contextCacheTime = $bigSegments['userCacheTimeMs'] ?? 0; + if ($contextCacheTime) { + $contextCacheTime /= 1_000; + } + $statusPollInterval = $bigSegments['statusPollIntervalMs'] ?? null; + if ($statusPollInterval) { + $statusPollInterval /= 1_000; + } + $staleAfter = $bigSegments['staleAfterMs'] ?? null; + if ($staleAfter) { + $staleAfter /= 1_000; + } + + + $cache = new FilesystemAdapter(defaultLifetime: $contextCacheTime); + + if ($resetBigSegmentsStore) { + $cache->clear(); + } + + $bigSegmentsConfig = new BigSegmentsConfig( + store: $store, + cache: $cache, + statusPollInterval: $statusPollInterval, + staleAfter: $staleAfter + ); + + $options['big_segments'] = $bigSegmentsConfig; + } + return new LDClient($sdkKey, $options); } @@ -104,6 +140,9 @@ public function doCommand(mixed $reqParams): mixed case 'migrationOperation': return $this->doMigrationOperation($commandParams); + case 'getBigSegmentStoreStatus': + return $this->doBigSegmentsStoreStatus(); + default: return false; // means invalid command } @@ -301,6 +340,16 @@ private function doMigrationOperation(array $params): array return ['result' => $result->authoritative->isSuccessful() ? $result->authoritative->value : $result->authoritative->error]; } + private function doBigSegmentsStoreStatus(): array + { + $status = $this->_client->getBigSegmentStatusProvider()->status(); + + return [ + 'available' => $status->isAvailable(), + 'stale' => $status->isStale(), + ]; + } + private function makeContext(array $data): LDContext { return LDContext::fromJson($data); diff --git a/test-service/TestService.php b/test-service/TestService.php index 7cb13c9e..8c82bf23 100644 --- a/test-service/TestService.php +++ b/test-service/TestService.php @@ -78,7 +78,8 @@ public function getStatus(): array 'event-sampling', 'inline-context', 'anonymous-redaction', - 'client-prereq-events' + 'client-prereq-events', + 'big-segments' ], 'clientVersion' => \LaunchDarkly\LDClient::VERSION ]; @@ -88,7 +89,7 @@ public function createClient(mixed $params): string { $this->_logger->info("Creating client with parameters: " . json_encode($params)); - $client = new SdkClientEntity($params, $this->_logger); // just to verify that the config is valid + $client = new SdkClientEntity($params, true, $this->_logger); // just to verify that the config is valid return $this->_store->addClientParams($params); } @@ -110,7 +111,8 @@ private function getClient(string $id): ?SdkClientEntity if ($params === null) { return null; } - return new SdkClientEntity($params); + + return new SdkClientEntity($params, false); } // The following methods for normalizing parsed JSON are a workaround for PHP's inability to distinguish diff --git a/test-service/composer.json b/test-service/composer.json index dec94a0a..85415f2f 100644 --- a/test-service/composer.json +++ b/test-service/composer.json @@ -13,7 +13,8 @@ "mikecao/flight": "^2", "monolog/monolog": "^2", "php": ">=8.1", - "psr/log": "1.*" + "psr/log": "1.*", + "symfony/cache": "^6.4|^7.2" }, "autoload": { "psr-4": { diff --git a/tests/BigSegmentsStoreImpl.php b/tests/BigSegmentsStoreImpl.php new file mode 100644 index 00000000..e8d240f1 --- /dev/null +++ b/tests/BigSegmentsStoreImpl.php @@ -0,0 +1,29 @@ + $metadata + * @param array> $memberships + */ + public function __construct(private array $metadata, private array $memberships) + { + } + + public function getMetadata(): BigSegmentsStoreMetadata + { + return array_shift($this->metadata); + } + + public function getMembership(string $contextHash): ?array + { + return array_shift($this->memberships); + } +} diff --git a/tests/FeatureFlagsStateTest.php b/tests/FeatureFlagsStateTest.php index c2abfade..040e0506 100644 --- a/tests/FeatureFlagsStateTest.php +++ b/tests/FeatureFlagsStateTest.php @@ -38,7 +38,7 @@ class FeatureFlagsStateTest extends \PHPUnit\Framework\TestCase 'trackEvents' => true, 'debugEventsUntilDate' => 1000 ]; - + private static function irrelevantReason(): EvaluationReason { return EvaluationReason::off(); @@ -56,7 +56,7 @@ public function testCanGetFlagValue() public function testUnknownFlagReturnsNullValue() { $state = new FeatureFlagsState(true); - + $this->assertNull($state->getFlagValue('key1')); } diff --git a/tests/Impl/BigSegments/StoreStatusProviderTest.php b/tests/Impl/BigSegments/StoreStatusProviderTest.php new file mode 100644 index 00000000..82f6c155 --- /dev/null +++ b/tests/Impl/BigSegments/StoreStatusProviderTest.php @@ -0,0 +1,139 @@ +status(); + $this->assertTrue($status->isAvailable()); + $this->assertTrue($status->isStale()); + + $status = $provider->status(); + $this->assertTrue($status->isAvailable()); + $this->assertFalse($status->isStale()); + + $status = $provider->status(); + $this->assertFalse($status->isAvailable()); + $this->assertTrue($status->isStale()); + + $status = $provider->status(); + $this->assertFalse($status->isAvailable()); + $this->assertFalse($status->isStale()); + } + + public function testListenersAreNotifiedWhenStatusIsChanged(): void + { + $provider = new BigSegments\StoreStatusProvider( + function (): Types\BigSegmentsStoreStatus { + return new Types\BigSegmentsStoreStatus(true, false); + }, + new \Psr\Log\NullLogger() + ); + + $firstListener = new SimpleListener(); + $secondListener = new SimpleListener(); + + $provider->attach($firstListener); + $provider->attach($secondListener); + + $provider->detach($firstListener); + $provider->updateStatus(new Types\BigSegmentsStoreStatus(true, true)); + + $this->assertNull($firstListener->old); + $this->assertEquals(0, $firstListener->callCount); + + $this->assertNull($secondListener->old); + $this->assertTrue($secondListener->new->isAvailable()); + $this->assertTrue($secondListener->new->isStale()); + $this->assertEquals(1, $secondListener->callCount); + } + + public function testListenersIgnoredIfStatusDoesNotChange(): void + { + $provider = new BigSegments\StoreStatusProvider( + function (): Types\BigSegmentsStoreStatus { + return new Types\BigSegmentsStoreStatus(true, false); + }, + new \Psr\Log\NullLogger() + ); + + $listener = new SimpleListener(); + $provider->attach($listener); + + $provider->updateStatus(new Types\BigSegmentsStoreStatus(true, false)); + $this->assertEquals(1, $listener->callCount); + + $provider->updateStatus(new Types\BigSegmentsStoreStatus(true, false)); + $this->assertEquals(1, $listener->callCount); + } + + public function testExceptionsInListenersDoNotHaltExecution(): void + { + $provider = new BigSegments\StoreStatusProvider( + function (): Types\BigSegmentsStoreStatus { + return new Types\BigSegmentsStoreStatus(true, false); + }, + new \Psr\Log\NullLogger() + ); + + $firstListener = new ExceptionListener(); + $secondListener = new SimpleListener(); + $provider->attach($firstListener); + $provider->attach($secondListener); + + $provider->updateStatus(new Types\BigSegmentsStoreStatus(true, false)); + + $this->assertEquals(1, $firstListener->callCount); + + $this->assertNull($secondListener->old); + $this->assertTrue($secondListener->new->isAvailable()); + $this->assertFalse($secondListener->new->isStale()); + $this->assertEquals(1, $secondListener->callCount); + } +} + +class SimpleListener implements BigSegmentStatusListener +{ + public ?BigSegmentsStoreStatus $old = null; + public ?BigSegmentsStoreStatus $new = null; + public int $callCount = 0; + + public function statusChanged(?BigSegmentsStoreStatus $old, BigSegmentsStoreStatus $new): void + { + $this->callCount++; + $this->old = $old; + $this->new = $new; + } +} + +class ExceptionListener implements BigSegmentStatusListener +{ + public int $callCount = 0; + + public function statusChanged(?BigSegmentsStoreStatus $old, BigSegmentsStoreStatus $new): void + { + $this->callCount++; + throw new \Exception("test exception"); + } +} diff --git a/tests/Impl/Evaluation/EvaluatorBigSegmentsTest.php b/tests/Impl/Evaluation/EvaluatorBigSegmentsTest.php new file mode 100644 index 00000000..54c9637a --- /dev/null +++ b/tests/Impl/Evaluation/EvaluatorBigSegmentsTest.php @@ -0,0 +1,167 @@ +generation(100) + ->unbounded(true) + ->build(); + + $store = new BigSegmentsStoreImpl([new BigSegmentsStoreMetadata(10)], [['test.g100' => true]]); + + $this->assertTrue(self::bigSegmentsMatchesContext($store, $segment, $defaultContext)); + } + + public function testExplicitExcludeContext(): void + { + global $defaultContext; + $segment = ModelBuilders::segmentBuilder('test') + ->generation(100) + ->unbounded(true) + ->build(); + + $store = new BigSegmentsStoreImpl([new BigSegmentsStoreMetadata(10)], [['test.g100' => false]]); + + $this->assertFalse(self::bigSegmentsMatchesContext($store, $segment, $defaultContext)); + } + + public function testImplicitExcludeContext(): void + { + global $defaultContext; + $segment = ModelBuilders::segmentBuilder('test') + ->generation(100) + ->unbounded(true) + ->build(); + + // The membership query is successful, but has no segment data. We consider this a miss. + $store = new BigSegmentsStoreImpl([new BigSegmentsStoreMetadata(10)], [[]]); + + $this->assertFalse(self::bigSegmentsMatchesContext($store, $segment, $defaultContext)); + } + + public function testMissingGenerationCausesMiss(): void + { + global $defaultContext; + $segment = ModelBuilders::segmentBuilder('test') + ->unbounded(true) + ->build(); + + $store = new BigSegmentsStoreImpl([new BigSegmentsStoreMetadata(10)], [['test.g100' => true]]); + + $this->assertFalse(self::bigSegmentsMatchesContext($store, $segment, $defaultContext)); + } + + public function testWrongContextCausesMiss(): void + { + global $defaultContext; + $segment = ModelBuilders::segmentBuilder('test') + ->unbounded(true) + ->generation(100) + ->unboundedContextKind('org') + ->build(); + + $store = new BigSegmentsStoreImpl([new BigSegmentsStoreMetadata(10)], [['test.g100' => true]]); + + $this->assertFalse(self::bigSegmentsMatchesContext($store, $segment, $defaultContext)); + } + + public function testCanQueryForMembershipMultipleTimes(): void + { + global $defaultContext; + $segment = ModelBuilders::segmentBuilder('test') + ->generation(100) + ->unbounded(true) + ->build(); + + $store = new BigSegmentsStoreImpl([new BigSegmentsStoreMetadata(10), new BigSegmentsStoreMetadata(10)], [['test.g100' => false], ['test.g100' => true]]); + + $this->assertFalse(self::bigSegmentsMatchesContext($store, $segment, $defaultContext)); + $this->assertTrue(self::bigSegmentsMatchesContext($store, $segment, $defaultContext)); + } + + public function testMembershipIsRememberedForLengthOfEvaluation(): void + { + global $defaultContext; + $segment1 = ModelBuilders::segmentBuilder('test1') + ->generation(100) + ->unbounded(true) + ->build(); + $segment2 = ModelBuilders::segmentBuilder('test2') + ->generation(300) + ->unbounded(true) + ->build(); + + $store = new BigSegmentsStoreImpl([new BigSegmentsStoreMetadata(10)], [['test1.g100' => true, 'test2.g300' => true], ['test1.g100' => true, 'test2.g300' => false]]); + $evaluator = self::getEvaluator($store, $defaultContext, [$segment1, $segment2]); + $flag = ModelBuilders::booleanFlagWithClauses(ModelBuilders::clauseMatchingSegment($segment1), ModelBuilders::clauseMatchingSegment($segment2)); + + $detail = $evaluator->evaluate($flag, $defaultContext, EvaluatorTestUtil::expectNoPrerequisiteEvals())->getDetail(); + $this->assertTrue($detail->getValue()); + + $detail = $evaluator->evaluate($flag, $defaultContext, EvaluatorTestUtil::expectNoPrerequisiteEvals())->getDetail(); + $this->assertFalse($detail->getValue()); + } + + public function testSegmentLogicFallsThroughToRules(): void + { + global $defaultContext; + $segment = ModelBuilders::segmentBuilder('test') + ->generation(100) + ->unbounded(true) + ->rule( + ModelBuilders::segmentRuleBuilder() + ->clause(ModelBuilders::clauseMatchingContext($defaultContext)) + ->weight(100000) + ->build() + ) + ->build(); + + $store = new BigSegmentsStoreImpl([new BigSegmentsStoreMetadata(10)], [null]); + + $this->assertTrue(self::bigSegmentsMatchesContext($store, $segment, $defaultContext)); + } + + private static function bigSegmentsMatchesContext(BigSegmentsStore $store, Segment $segment, LDContext $context): bool + { + $evaluator = self::getEvaluator($store, $context, [$segment]); + $flag = ModelBuilders::booleanFlagWithClauses(ModelBuilders::clauseMatchingSegment($segment)); + + $detail = $evaluator->evaluate($flag, $context, EvaluatorTestUtil::expectNoPrerequisiteEvals())->getDetail(); + if ($detail->getValue() === null) { + self::assertTrue(false, "Evaluation failed with reason: " . json_encode($detail->getReason())); + } + return $detail->getValue(); + } + + private static function getEvaluator(BigSegmentsStore $store, LDContext $context, array $segments): Evaluator + { + $logger = EvaluatorTestUtil::testLogger(); + $config = new BigSegmentsConfig(store: $store); + $manager = new StoreManager(config: $config, logger: $logger); + + $requester = new MockFeatureRequester(); + foreach ($segments as $segment) { + $requester->addSegment($segment); + } + return new Evaluator($requester, $manager, $logger); + } +} diff --git a/tests/Impl/Evaluation/EvaluatorClauseTest.php b/tests/Impl/Evaluation/EvaluatorClauseTest.php index 3e7b8b75..35049acc 100644 --- a/tests/Impl/Evaluation/EvaluatorClauseTest.php +++ b/tests/Impl/Evaluation/EvaluatorClauseTest.php @@ -2,12 +2,14 @@ namespace LaunchDarkly\Tests\Impl\Evaluation; +use LaunchDarkly\Impl\BigSegments\StoreManager; use LaunchDarkly\Impl\Evaluation\Evaluator; use LaunchDarkly\Impl\Model\Clause; use LaunchDarkly\Impl\Model\FeatureFlag; use LaunchDarkly\LDContext; use LaunchDarkly\Tests\MockFeatureRequester; use LaunchDarkly\Tests\ModelBuilders; +use LaunchDarkly\Types\BigSegmentsConfig; use PHPUnit\Framework\TestCase; class EvaluatorClauseTest extends TestCase @@ -29,7 +31,7 @@ private function assertMatchClause(Evaluator $eval, Clause $clause, LDContext $c { self::assertMatch($eval, ModelBuilders::booleanFlagWithClauses($clause), $context, $expectMatch); } - + public function testClauseCanMatchBuiltInAttribute() { $clause = ModelBuilders::clause(null, 'name', 'in', 'Bob'); @@ -135,7 +137,9 @@ public function testSegmentMatchClauseRetrievesSegmentFromStore() $segment = ModelBuilders::segmentBuilder('segkey')->included($context->getKey())->build(); $requester = new MockFeatureRequester(); $requester->addSegment($segment); - $evaluator = new Evaluator($requester); + + $storeManager = new StoreManager(config: new BigSegmentsConfig(store: null), logger: EvaluatorTestUtil::testLogger()); + $evaluator = new Evaluator($requester, $storeManager); $clause = ModelBuilders::clauseMatchingSegment($segment); @@ -147,8 +151,9 @@ public function testSegmentMatchClauseFallsThroughWithNoErrorsIfSegmentNotFound( $context = LDContext::create('key'); $requester = new MockFeatureRequester(); $requester->expectQueryForUnknownSegment('segkey'); - $evaluator = new Evaluator($requester); - + $storeManager = new StoreManager(config: new BigSegmentsConfig(store: null), logger: EvaluatorTestUtil::testLogger()); + $evaluator = new Evaluator($requester, $storeManager); + $clause = ModelBuilders::clause(null, '', 'segmentMatch', 'segkey'); self::assertMatchClause($evaluator, $clause, $context, false); diff --git a/tests/Impl/Evaluation/EvaluatorPrerequisiteTest.php b/tests/Impl/Evaluation/EvaluatorPrerequisiteTest.php index 0052cf73..12e687c9 100644 --- a/tests/Impl/Evaluation/EvaluatorPrerequisiteTest.php +++ b/tests/Impl/Evaluation/EvaluatorPrerequisiteTest.php @@ -4,10 +4,12 @@ use LaunchDarkly\EvaluationDetail; use LaunchDarkly\EvaluationReason; +use LaunchDarkly\Impl\BigSegments\StoreManager; use LaunchDarkly\Impl\Evaluation\Evaluator; use LaunchDarkly\LDContext; use LaunchDarkly\Tests\MockFeatureRequester; use LaunchDarkly\Tests\ModelBuilders; +use LaunchDarkly\Types\BigSegmentsConfig; use PHPUnit\Framework\TestCase; $defaultContext = LDContext::create('foo'); @@ -30,7 +32,8 @@ public function testFlagReturnsOffVariationIfPrerequisiteIsNotFound() $requester = new MockFeatureRequester(); $requester->expectQueryForUnknownFlag('feature1'); - $evaluator = new Evaluator($requester); + $storeManager = new StoreManager(config: new BigSegmentsConfig(store: null), logger: EvaluatorTestUtil::testLogger()); + $evaluator = new Evaluator($requester, $storeManager); $result = $evaluator->evaluate($flag, LDContext::create('user'), EvaluatorTestUtil::expectNoPrerequisiteEvals()); $detail = new EvaluationDetail('off', 1, EvaluationReason::prerequisiteFailed('feature1')); @@ -50,7 +53,8 @@ public function testFlagReturnsOffVariationAndEventIfPrerequisiteIsOff() $requester = new MockFeatureRequester(); $requester->addFlag($flag1); - $evaluator = new Evaluator($requester); + $storeManager = new StoreManager(config: new BigSegmentsConfig(store: null), logger: EvaluatorTestUtil::testLogger()); + $evaluator = new Evaluator($requester, $storeManager); $recorder = EvaluatorTestUtil::prerequisiteRecorder(); $result = $evaluator->evaluate($flag0, LDContext::create('user'), $recorder->record()); @@ -76,7 +80,8 @@ public function testFlagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() $requester = new MockFeatureRequester(); $requester->addFlag($flag1); - $evaluator = new Evaluator($requester); + $storeManager = new StoreManager(config: new BigSegmentsConfig(store: null), logger: EvaluatorTestUtil::testLogger()); + $evaluator = new Evaluator($requester, $storeManager); $recorder = EvaluatorTestUtil::prerequisiteRecorder(); $result = $evaluator->evaluate($flag0, LDContext::create('user'), $recorder->record()); @@ -102,7 +107,8 @@ public function testFlagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAn $requester = new MockFeatureRequester(); $requester->addFlag($flag1); - $evaluator = new Evaluator($requester); + $storeManager = new StoreManager(config: new BigSegmentsConfig(store: null), logger: EvaluatorTestUtil::testLogger()); + $evaluator = new Evaluator($requester, $storeManager); $recorder = EvaluatorTestUtil::prerequisiteRecorder(); $result = $evaluator->evaluate($flag0, LDContext::create('user'), $recorder->record()); @@ -140,7 +146,8 @@ public function testPrerequisiteCycleDetection($depth) $flags[] = $flag; $requester->addFlag($flag); } - $evaluator = new Evaluator($requester); + $storeManager = new StoreManager(config: new BigSegmentsConfig(store: null), logger: EvaluatorTestUtil::testLogger()); + $evaluator = new Evaluator($requester, $storeManager); $result = $evaluator->evaluate($flags[0], LDContext::create('user'), EvaluatorTestUtil::expectNoPrerequisiteEvals()); // Note, we specified expectNoPrerequisiteEvals() above because we do not expect the evaluator diff --git a/tests/Impl/Evaluation/EvaluatorSegmentTest.php b/tests/Impl/Evaluation/EvaluatorSegmentTest.php index d9a219af..9bee41ad 100644 --- a/tests/Impl/Evaluation/EvaluatorSegmentTest.php +++ b/tests/Impl/Evaluation/EvaluatorSegmentTest.php @@ -3,12 +3,14 @@ namespace LaunchDarkly\Tests\Impl\Evaluation; use LaunchDarkly\EvaluationReason; +use LaunchDarkly\Impl\BigSegments\StoreManager; use LaunchDarkly\Impl\Evaluation\Evaluator; use LaunchDarkly\Impl\Evaluation\EvaluatorBucketing; use LaunchDarkly\Impl\Model\Segment; use LaunchDarkly\LDContext; use LaunchDarkly\Tests\MockFeatureRequester; use LaunchDarkly\Tests\ModelBuilders; +use LaunchDarkly\Types\BigSegmentsConfig; use PHPUnit\Framework\TestCase; $defaultContext = LDContext::create('foo'); @@ -68,7 +70,7 @@ public function testExcludedKeyForContextKind() $this->assertTrue(self::segmentMatchesContext($segment, $c2)); $this->assertFalse(self::segmentMatchesContext($segment, $multi)); } - + public function testMatchingRuleWithFullRollout() { global $defaultContext; @@ -213,7 +215,8 @@ public function testSegmentReferencingSegment($depth) $segments[] = $segment; $requester->addSegment($segment); } - $evaluator = new Evaluator($requester); + $storeManager = new StoreManager(config: new BigSegmentsConfig(store: null), logger: EvaluatorTestUtil::testLogger()); + $evaluator = new Evaluator($requester, $storeManager); $flag = ModelBuilders::booleanFlagWithClauses(ModelBuilders::clauseMatchingSegment($segments[0])); @@ -243,7 +246,8 @@ public function testSegmentCycleDetection($depth) $segments[] = $segment; $requester->addSegment($segment); } - $evaluator = new Evaluator($requester); + $storeManager = new StoreManager(config: new BigSegmentsConfig(store: null), logger: EvaluatorTestUtil::testLogger()); + $evaluator = new Evaluator($requester, $storeManager); $flag = ModelBuilders::booleanFlagWithClauses(ModelBuilders::clauseMatchingSegment($segments[0])); @@ -257,7 +261,8 @@ private static function segmentMatchesContext(Segment $segment, LDContext $conte $requester = new MockFeatureRequester(); $requester->addSegment($segment); - $evaluator = new Evaluator($requester, EvaluatorTestUtil::testLogger()); + $storeManager = new StoreManager(config: new BigSegmentsConfig(store: null), logger: EvaluatorTestUtil::testLogger()); + $evaluator = new Evaluator($requester, $storeManager); $detail = $evaluator->evaluate($flag, $context, EvaluatorTestUtil::expectNoPrerequisiteEvals())->getDetail(); if ($detail->getValue() === null) { diff --git a/tests/Impl/Evaluation/EvaluatorTestUtil.php b/tests/Impl/Evaluation/EvaluatorTestUtil.php index 6103902f..70df32be 100644 --- a/tests/Impl/Evaluation/EvaluatorTestUtil.php +++ b/tests/Impl/Evaluation/EvaluatorTestUtil.php @@ -2,9 +2,11 @@ namespace LaunchDarkly\Tests\Impl\Evaluation; +use LaunchDarkly\Impl\BigSegments\StoreManager; use LaunchDarkly\Impl\Evaluation\Evaluator; use LaunchDarkly\Impl\Evaluation\PrerequisiteEvaluationRecord; use LaunchDarkly\Tests\MockFeatureRequester; +use LaunchDarkly\Types\BigSegmentsConfig; use Monolog\Handler\ErrorLogHandler; use Monolog\Logger; use Psr\Log\LoggerInterface; @@ -13,7 +15,11 @@ class EvaluatorTestUtil { public static function basicEvaluator(): Evaluator { - return new Evaluator(new MockFeatureRequester()); + return new Evaluator( + new MockFeatureRequester(), + new StoreManager(config: new BigSegmentsConfig(store: null), logger: self::testLogger()), + self::testLogger() + ); } public static function expectNoPrerequisiteEvals(): callable diff --git a/tests/Impl/Evaluation/RolloutRandomizationConsistencyTest.php b/tests/Impl/Evaluation/RolloutRandomizationConsistencyTest.php index 8587d663..c52b85cf 100644 --- a/tests/Impl/Evaluation/RolloutRandomizationConsistencyTest.php +++ b/tests/Impl/Evaluation/RolloutRandomizationConsistencyTest.php @@ -4,12 +4,14 @@ use LaunchDarkly\EvaluationDetail; use LaunchDarkly\EvaluationReason; +use LaunchDarkly\Impl\BigSegments\StoreManager; use LaunchDarkly\Impl\Evaluation\EvalResult; use LaunchDarkly\Impl\Evaluation\Evaluator; use LaunchDarkly\Impl\Evaluation\EvaluatorBucketing; use LaunchDarkly\Impl\Model\FeatureFlag; use LaunchDarkly\LDContext; use LaunchDarkly\Tests\MockFeatureRequester; +use LaunchDarkly\Types\BigSegmentsConfig; use PHPUnit\Framework\TestCase; /** @@ -86,7 +88,8 @@ public function testVariationIndexForContext() false ); - $evaluator = new Evaluator(static::$requester); + $storeManager = new StoreManager(config: new BigSegmentsConfig(store: null), logger: EvaluatorTestUtil::testLogger()); + $evaluator = new Evaluator(static::$requester, $storeManager); $context1 = LDContext::create('userKeyA'); $result1 = $evaluator->evaluate($flag, $context1, EvaluatorTestUtil::expectNoPrerequisiteEvals()); diff --git a/tests/Integrations/FileDataFeatureRequesterTest.php b/tests/Integrations/FileDataFeatureRequesterTest.php index 75da1602..6a9f055c 100644 --- a/tests/Integrations/FileDataFeatureRequesterTest.php +++ b/tests/Integrations/FileDataFeatureRequesterTest.php @@ -2,9 +2,12 @@ namespace LaunchDarkly\Tests\Integrations; +use LaunchDarkly\Impl\BigSegments\StoreManager; use LaunchDarkly\Impl\Evaluation\Evaluator; use LaunchDarkly\Integrations\Files; use LaunchDarkly\LDContext; +use LaunchDarkly\Tests\Impl\Evaluation\EvaluatorTestUtil; +use LaunchDarkly\Types\BigSegmentsConfig; use PHPUnit\Framework\TestCase; class FileDataFeatureRequesterTest extends TestCase @@ -35,7 +38,8 @@ public function testShortcutFlagCanBeEvaluated() $fr = Files::featureRequester("./tests/filedata/all-properties.json"); $flag2 = $fr->getFeature("flag2"); $this->assertEquals("flag2", $flag2->getKey()); - $evaluator = new Evaluator($fr); + $storeManager = new StoreManager(config: new BigSegmentsConfig(store: null), logger: EvaluatorTestUtil::testLogger()); + $evaluator = new Evaluator($fr, $storeManager); $result = $evaluator->evaluate($flag2, LDContext::create("user"), null); $this->assertEquals("value2", $result->getDetail()->getValue()); } diff --git a/tests/LDClientTest.php b/tests/LDClientTest.php index 7f9af57a..025309e7 100644 --- a/tests/LDClientTest.php +++ b/tests/LDClientTest.php @@ -3,6 +3,7 @@ namespace LaunchDarkly\Tests; use InvalidArgumentException; +use LaunchDarkly\BigSegmentsEvaluationStatus; use LaunchDarkly\EvaluationDetail; use LaunchDarkly\EvaluationReason; use LaunchDarkly\Impl\Model\FeatureFlag; @@ -12,7 +13,11 @@ use LaunchDarkly\Migrations\OpTracker; use LaunchDarkly\Migrations\Origin; use LaunchDarkly\Migrations\Stage; +use LaunchDarkly\Subsystems\BigSegmentStatusListener; use LaunchDarkly\Tests\Impl\Evaluation\EvaluatorTestUtil; +use LaunchDarkly\Types\BigSegmentsConfig; +use LaunchDarkly\Types\BigSegmentsStoreMetadata; +use LaunchDarkly\Types\BigSegmentsStoreStatus; use Psr\Log\LoggerInterface; class LDClientTest extends \PHPUnit\Framework\TestCase @@ -28,7 +33,12 @@ public function testDefaultCtor() { $this->assertInstanceOf(LDClient::class, new LDClient("BOGUS_SDK_KEY")); } - + /** + * @param mixed $key + * @param mixed $value + * @param mixed $samplingRatio + * @param mixed $excludeFromSummaries + */ private function makeOffFlagWithValue($key, $value, $samplingRatio = 1, $excludeFromSummaries = false) { return ModelBuilders::flagBuilder($key) @@ -41,7 +51,9 @@ private function makeOffFlagWithValue($key, $value, $samplingRatio = 1, $exclude ->excludeFromSummaries($excludeFromSummaries) ->build(); } - + /** + * @param mixed $key + */ private function makeFlagThatEvaluatesToNull($key) { return ModelBuilders::flagBuilder($key) @@ -51,14 +63,15 @@ private function makeFlagThatEvaluatesToNull($key) ->fallthroughVariation(0) ->build(); } - - private function makeClient($overrideOptions = []) + /** + * @param mixed $overrideOptions + */ + private function makeClient($overrideOptions = []): LDClient { $options = [ 'feature_requester' => $this->mockRequester, 'event_processor' => new MockEventProcessor() ]; - $x = array_merge($options, $overrideOptions); return new LDClient("someKey", array_merge($options, $overrideOptions)); } @@ -974,4 +987,265 @@ public function testCanDetermineCorrectStage(Stage $stage): void $this->assertEquals($stage, $result['stage']); $this->assertInstanceOf(OpTracker::class, $result['tracker']); } + + public function testCanCheckBigSegmentStatus(): void + { + $store = new BigSegmentsStoreImpl([ + new BigSegmentsStoreMetadata(lastUpToDate: 0), + ], []); + + $config = new BigSegmentsConfig(store: $store); + $client = $this->makeClient(['big_segments' => $config]); + $provider = $client->getBigSegmentStatusProvider(); + + $status = $provider->status(); + $this->assertTrue($status->isAvailable()); + $this->assertTrue($status->isStale()); + } + + public function testCanCheckBigSegmentStatusWhenUnconfigured(): void + { + $client = $this->makeClient(); + $provider = $client->getBigSegmentStatusProvider(); + + $status = $provider->status(); + $this->assertFalse($status->isAvailable()); + $this->assertFalse($status->isStale()); + } + + public function testEachCheckCausesALookup(): void + { + $store = new BigSegmentsStoreImpl([ + new BigSegmentsStoreMetadata(lastUpToDate: 0), + new BigSegmentsStoreMetadata(lastUpToDate: time()), + ], []); + + $config = new BigSegmentsConfig(store: $store); + $client = $this->makeClient(['big_segments' => $config]); + $provider = $client->getBigSegmentStatusProvider(); + + $status = $provider->status(); + $this->assertTrue($status->isAvailable()); + $this->assertTrue($status->isStale()); + + $status = $provider->status(); + $this->assertTrue($status->isAvailable()); + $this->assertFalse($status->isStale()); + } + + public function testCanControlFreshnessThroughConfig(): void + { + $now = time(); + $store = new BigSegmentsStoreImpl([ + new BigSegmentsStoreMetadata(lastUpToDate: $now - 100), + new BigSegmentsStoreMetadata(lastUpToDate: $now - 1_000), + ], []); + + $config = new BigSegmentsConfig(store: $store, staleAfter: 500); + $client = $this->makeClient(['big_segments' => $config]); + $provider = $client->getBigSegmentStatusProvider(); + + $status = $provider->status(); + $this->assertFalse($status->isStale()); + + $status = $provider->status(); + $this->assertTrue($status->isStale()); + } + + public function testReportsUnconfiguredBigSegmentsEvaluation(): void + { + $segment = ModelBuilders::segmentBuilder('test') + ->generation(100) + ->unbounded(true) + ->build(); + $flag = ModelBuilders::booleanFlagWithClauses( + ModelBuilders::clauseMatchingSegment($segment) + ); + $this->mockRequester->addFlag($flag); + $this->mockRequester->addSegment($segment); + + $client = $this->makeClient(); + $detail = $client->variationDetail($flag->getKey(), LDContext::create('userkey'), false); + + $this->assertEquals(BigSegmentsEvaluationStatus::NOT_CONFIGURED, $detail->getReason()->bigSegmentsEvaluationStatus()); + } + + public function testReportsHealthyBigSegmentsEvaluationStatus(): void + { + $segment = ModelBuilders::segmentBuilder('test') + ->generation(100) + ->unbounded(true) + ->build(); + $flag = ModelBuilders::booleanFlagWithClauses( + ModelBuilders::clauseMatchingSegment($segment) + ); + $this->mockRequester->addFlag($flag); + $this->mockRequester->addSegment($segment); + + $store = new BigSegmentsStoreImpl([ + new BigSegmentsStoreMetadata(lastUpToDate: time()), + ], []); + + $config = new BigSegmentsConfig(store: $store); + $client = $this->makeClient(['big_segments' => $config]); + $detail = $client->variationDetail($flag->getKey(), LDContext::create('userkey'), false); + + $this->assertEquals(BigSegmentsEvaluationStatus::HEALTHY, $detail->getReason()->bigSegmentsEvaluationStatus()); + } + + public function testReportsStaleBigSegmentsEvaluationStatus(): void + { + $segment = ModelBuilders::segmentBuilder('test') + ->generation(100) + ->unbounded(true) + ->build(); + $flag = ModelBuilders::booleanFlagWithClauses( + ModelBuilders::clauseMatchingSegment($segment) + ); + $this->mockRequester->addFlag($flag); + $this->mockRequester->addSegment($segment); + + $store = new BigSegmentsStoreImpl([ + new BigSegmentsStoreMetadata(lastUpToDate: time() - 1_000), + ], []); + + $config = new BigSegmentsConfig(store: $store, staleAfter: 100); + $client = $this->makeClient(['big_segments' => $config]); + $detail = $client->variationDetail($flag->getKey(), LDContext::create('userkey'), false); + + $this->assertEquals(BigSegmentsEvaluationStatus::STALE, $detail->getReason()->bigSegmentsEvaluationStatus()); + } + + public function testCheckingBigSegmentStatusPreventsEvaluationFromNeedingTo(): void + { + $segment = ModelBuilders::segmentBuilder('test') + ->generation(100) + ->unbounded(true) + ->build(); + $flag = ModelBuilders::booleanFlagWithClauses( + ModelBuilders::clauseMatchingSegment($segment) + ); + $this->mockRequester->addFlag($flag); + $this->mockRequester->addSegment($segment); + + $store = new BigSegmentsStoreImpl([ + new BigSegmentsStoreMetadata(lastUpToDate: time()), + new BigSegmentsStoreMetadata(lastUpToDate: time() - 1000), + ], []); + + $config = new BigSegmentsConfig(store: $store, staleAfter: 500); + $client = $this->makeClient(['big_segments' => $config]); + $provider = $client->getBigSegmentStatusProvider(); + + $status = $provider->status(); + $this->assertTrue($status->isAvailable()); + $this->assertFalse($status->isStale()); + + // Should be STALE if it actually made a new request. However, it isn't + // configured to make another status check that fast, so it uses what + // it last knew, which is that it isn't stale. + $detail = $client->variationDetail($flag->getKey(), LDContext::create('userkey'), false); + $this->assertEquals(BigSegmentsEvaluationStatus::HEALTHY, $detail->getReason()->bigSegmentsEvaluationStatus()); + + $status = $provider->status(); + $this->assertTrue($status->isAvailable()); + $this->assertTrue($status->isStale()); + } + + public function testBigSegmentStatusListeners(): void + { + $segment = ModelBuilders::segmentBuilder('test') + ->generation(100) + ->unbounded(true) + ->build(); + $flag = ModelBuilders::booleanFlagWithClauses( + ModelBuilders::clauseMatchingSegment($segment) + ); + $this->mockRequester->addFlag($flag); + $this->mockRequester->addSegment($segment); + + $now = time(); + $store = new BigSegmentsStoreImpl([ + new BigSegmentsStoreMetadata(lastUpToDate: $now), + new BigSegmentsStoreMetadata(lastUpToDate: $now - 1000), + new BigSegmentsStoreMetadata(lastUpToDate: $now), + ], []); + + $config = new BigSegmentsConfig(store: $store, staleAfter: 500); + $client = $this->makeClient(['big_segments' => $config]); + $provider = $client->getBigSegmentStatusProvider(); + + $subjects = []; + $listener = new MockBigSegmentStatusListener( + function (?BigSegmentsStoreStatus $old, BigSegmentsStoreStatus $new) use (&$subjects) { + $subjects[] = ['old' => $old, 'new' => $new]; + } + ); + $provider->attach($listener); + + // Triggers a status lookup + $client->variationDetail($flag->getKey(), LDContext::create('userkey'), false); + + // Force 2 more + $provider->status(); + $provider->status(); + + $this->assertCount(3, $subjects); + + $old = $subjects[0]['old']; + $new = $subjects[0]['new']; + $this->assertNull($old); + $this->assertFalse($new->isStale()); + + $old = $subjects[1]['old']; + $new = $subjects[1]['new']; + $this->assertFalse($old->isStale()); + $this->assertTrue($new->isStale()); + + $old = $subjects[2]['old']; + $new = $subjects[2]['new']; + $this->assertTrue($old->isStale()); + $this->assertFalse($new->isStale()); + } + + public function testBigSegmentStatusListenerExceptionsDoNotHaltException(): void + { + $now = time(); + $store = new BigSegmentsStoreImpl([ + new BigSegmentsStoreMetadata(lastUpToDate: $now), + ], []); + + $config = new BigSegmentsConfig(store: $store, staleAfter: 500); + $client = $this->makeClient(['big_segments' => $config]); + $provider = $client->getBigSegmentStatusProvider(); + + $listener = new MockBigSegmentStatusListener(fn () => throw new \Exception('oops')); + $provider->attach($listener); + + try { + $provider->status(); + } catch (\Exception) { + $this->fail('The SDK should have swallowed the exception.'); + } + + $this->assertTrue(true, 'confirming that we did in fact get this far'); + } +} + +class MockBigSegmentStatusListener implements BigSegmentStatusListener +{ + private $fn; + + /** + * @param callable(BigSegmentsStoreStatus, BigSegmentsStoreStatus): void $fn + */ + public function __construct(callable $fn) + { + $this->fn = $fn; + } + + public function statusChanged(?BigSegmentsStoreStatus $old, BigSegmentsStoreStatus $new): void + { + ($this->fn)($old, $new); + } } diff --git a/tests/ModelBuilders.php b/tests/ModelBuilders.php index 44da68bc..fa3959fd 100644 --- a/tests/ModelBuilders.php +++ b/tests/ModelBuilders.php @@ -12,21 +12,33 @@ class ModelBuilders { + /** + * @return FlagBuilder + */ public static function flagBuilder(string $key) { return new FlagBuilder($key); } + /** + * @return FlagRuleBuilder + */ public static function flagRuleBuilder() { return new FlagRuleBuilder(); } + /** + * @return SegmentBuilder + */ public static function segmentBuilder(string $key) { return new SegmentBuilder($key); } + /** + * @return SegmentRuleBuilder + */ public static function segmentRuleBuilder() { return new SegmentRuleBuilder(); @@ -68,7 +80,7 @@ public static function flagRuleWithClauses(int $variation, Clause ...$clauses): { return self::flagRuleBuilder()->variation($variation)->clauses($clauses)->build(); } - + public static function negate(Clause $clause): Clause { return new Clause($clause->getContextKind(), $clause->getAttribute(), $clause->getOp(), $clause->getValues(), true); diff --git a/tests/SegmentBuilder.php b/tests/SegmentBuilder.php index 952b0a6e..da0a0ec8 100644 --- a/tests/SegmentBuilder.php +++ b/tests/SegmentBuilder.php @@ -18,6 +18,9 @@ class SegmentBuilder private array $_includedContexts = []; /** @var SegmentTarget[] */ private array $_excludedContexts = []; + private bool $_unbounded = false; + private ?string $_unboundedContextKind = null; + private ?int $_generation = null; private string $_salt = ''; /** @var SegmentRule[] */ private array $_rules = []; @@ -37,6 +40,9 @@ public function build(): Segment $this->_excluded, $this->_includedContexts, $this->_excludedContexts, + $this->_unbounded, + $this->_unboundedContextKind, + $this->_generation, $this->_salt, $this->_rules, $this->_deleted @@ -84,4 +90,22 @@ public function salt(string $salt): SegmentBuilder $this->_salt = $salt; return $this; } + + public function unbounded(bool $unbounded): SegmentBuilder + { + $this->_unbounded = $unbounded; + return $this; + } + + public function unboundedContextKind(?string $unboundedContextKind): SegmentBuilder + { + $this->_unboundedContextKind = $unboundedContextKind; + return $this; + } + + public function generation(?int $generation): SegmentBuilder + { + $this->_generation = $generation; + return $this; + } } diff --git a/tests/Types/BigSegmentsConfigTest.php b/tests/Types/BigSegmentsConfigTest.php new file mode 100644 index 00000000..639c67d2 --- /dev/null +++ b/tests/Types/BigSegmentsConfigTest.php @@ -0,0 +1,77 @@ +store); + self::assertEquals(Types\BigSegmentsConfig::DEFAULT_STATUS_POLL_INTERVAL, $config->statusPollInterval); + self::assertEquals(Types\BigSegmentsConfig::DEFAULT_STALE_AFTER, $config->staleAfter); + } + + /** + * @return array> + */ + public function nonNegativeValues(): array + { + return [ + [0, 0, 0], + [100, 100, 100], + [123, 456, 789], + ]; + } + + /** + * @dataProvider nonNegativeValues + */ + public function testCanSetToNonnegativeValues(int $contextCacheTime, int $statusPollInterval, int $staleAfter): void + { + $config = new Types\BigSegmentsConfig( + store: null, + contextCacheTime: $contextCacheTime, + statusPollInterval: $statusPollInterval, + staleAfter: $staleAfter, + ); + + self::assertNull($config->store); + self::assertEquals($contextCacheTime, $config->contextCacheTime); + self::assertEquals($statusPollInterval, $config->statusPollInterval); + self::assertEquals($staleAfter, $config->staleAfter); + } + + /** + * @return array> + */ + public function negativeValues(): array + { + return [ + [-1, -1, -1], + [-100, -100, -100], + [-123, -456, -789], + ]; + } + + /** + * @dataProvider negativeValues + */ + public function testNegativeValuesResetToDefaults(int $contextCacheTime, int $statusPollInterval, int $staleAfter): void + { + $config = new Types\BigSegmentsConfig( + store: null, + contextCacheTime: $contextCacheTime, + statusPollInterval: $statusPollInterval, + staleAfter: $staleAfter, + ); + + self::assertNull($config->store); + self::assertEquals($contextCacheTime, $config->contextCacheTime); + self::assertEquals(Types\BigSegmentsConfig::DEFAULT_STATUS_POLL_INTERVAL, $config->statusPollInterval); + self::assertEquals(Types\BigSegmentsConfig::DEFAULT_STALE_AFTER, $config->staleAfter); + } +}