Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add support for big segments #213

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
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 .github/actions/ci/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)'"
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
- 8080:8080

strategy:
fail-fast: false
matrix:
php-version: [8.1, 8.2]
use-lowest-dependencies: [true, false]
Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
36 changes: 36 additions & 0 deletions src/LaunchDarkly/BigSegmentsEvaluationStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace LaunchDarkly;

/**
* A status enum which represents the result of a Big Segment query involved in
* a flag evaluation.
*/
enum BigSegmentsEvaluationStatus: string
{
/**
* Indicates that the Big Segment query involved in the flag evaluation was
* successful, and that the segment state is considered up to date.
*/
case HEALTHY = 'HEALTHY';

/**
* Indicates that the Big Segment query involved in the flag evaluation was
* successful, but that the segment state may not be up to date.
*/
case STALE = 'STALE';

/**
* Indicates that Big Segments could not be queried for the flag evaluation
* because the SDK configuration did not include a Big Segment store.
*/
case NOT_CONFIGURED = 'NOT_CONFIGURED';

/**
* Indicates that the Big Segment query involved in the flag evaluation
* failed, for instance due to a database error.
*/
case STORE_ERROR = 'STORE_ERROR';
}
44 changes: 43 additions & 1 deletion src/LaunchDarkly/EvaluationReason.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ class EvaluationReason implements \JsonSerializable
private ?string $_ruleId;
private ?string $_prerequisiteKey;
private bool $_inExperiment;
private ?BigSegmentsEvaluationStatus $_bigSegmentsEvaluationStatus;

/**
* Creates a new instance of the OFF reason.
Expand Down Expand Up @@ -164,14 +165,37 @@ private function __construct(
?int $ruleIndex = null,
?string $ruleId = null,
?string $prerequisiteKey = null,
bool $inExperiment = false
bool $inExperiment = false,
BigSegmentsEvaluationStatus $bigSegmentsEvaluationStatus = null
) {
$this->_kind = $kind;
$this->_errorKind = $errorKind;
$this->_ruleIndex = $ruleIndex;
$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
);
}

/**
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
}
}
19 changes: 19 additions & 0 deletions src/LaunchDarkly/Impl/BigSegments/MembershipResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace LaunchDarkly\Impl\BigSegments;

use LaunchDarkly\BigSegmentsEvaluationStatus;

class MembershipResult
{
/**
* @param ?array<string, bool> $membership
*/
public function __construct(
public readonly ?array $membership,
public readonly BigSegmentsEvaluationStatus $status
) {
}
}
111 changes: 111 additions & 0 deletions src/LaunchDarkly/Impl/BigSegments/StoreManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

declare(strict_types=1);

namespace LaunchDarkly\Impl\BigSegments;

use DateTimeImmutable;
use Exception;
use LaunchDarkly\BigSegmentsEvaluationStatus;
use LaunchDarkly\Impl;
use LaunchDarkly\Subsystems;
use LaunchDarkly\Types;
use Psr\Log\LoggerInterface;

class StoreManager
{
private Types\BigSegmentsConfig $config;
private ?Subsystems\BigSegmentsStore $store;
private Impl\BigSegments\StoreStatusProvider $statusProvider;
private ?Types\BigSegmentsStoreStatus $lastStatus;
private ?DateTimeImmutable $lastStatusPollTime;

public function __construct(Types\BigSegmentsConfig $config, private readonly LoggerInterface $logger)
{
$this->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;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a little unclear in which cases null will be returned vs which cases a result with null membership and store error will come back.

}

$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));
}
}
78 changes: 78 additions & 0 deletions src/LaunchDarkly/Impl/BigSegments/StoreStatusProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

declare(strict_types=1);

namespace LaunchDarkly\Impl\BigSegments;

use Exception;
use LaunchDarkly\Subsystems;
use LaunchDarkly\Types;
use Psr\Log\LoggerInterface;
use SplObjectStorage;

class StoreStatusProvider implements Subsystems\BigSegmentStatusProvider
{
private SplObjectStorage $listeners;
/**
* @var callable(): Types\BigSegmentsStoreStatus
*/
private $statusFn;
private ?Types\BigSegmentsStoreStatus $lastStatus;
private LoggerInterface $logger;

/**
* @param callable(): Types\BigSegmentsStoreStatus $statusFn
*/
public function __construct(callable $statusFn, LoggerInterface $logger)
{
$this->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)();
}
}
5 changes: 5 additions & 0 deletions src/LaunchDarkly/Impl/Evaluation/EvalResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading