Skip to content

Commit

Permalink
feat: Add support for big segments
Browse files Browse the repository at this point in the history
  • Loading branch information
keelerm84 committed Jan 13, 2025
1 parent dd318af commit 62b63ba
Show file tree
Hide file tree
Showing 37 changed files with 1,642 additions and 48 deletions.
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": "^1.0|^2.0|^3.0",
"psr/log": "^1.0|^2.0|^3.0"
},
"require-dev": {
Expand Down
36 changes: 36 additions & 0 deletions src/LaunchDarkly/BigSegmentEvaluationStatus.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 BigSegmentEvaluationStatus: 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 ?BigSegmentEvaluationStatus $_bigSegmentEvaluationStatus;

/**
* 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,
BigSegmentEvaluationStatus $bigSegmentEvaluationStatus = null
) {
$this->_kind = $kind;
$this->_errorKind = $errorKind;
$this->_ruleIndex = $ruleIndex;
$this->_ruleId = $ruleId;
$this->_prerequisiteKey = $prerequisiteKey;
$this->_inExperiment = $inExperiment;
$this->_bigSegmentEvaluationStatus = $bigSegmentEvaluationStatus;
}

/**
* Returns a new EvaluationReason instance matching all the properties of
* this one, except for the big segment evaluation status.
*/
public function withBigSegmentEvaluationStatus(BigSegmentEvaluationStatus $bigSegmentEvaluationStatus): EvaluationReason
{
if ($this->_bigSegmentEvaluationStatus == $bigSegmentEvaluationStatus) {
return $this;
}

return new EvaluationReason(
$this->_kind,
$this->_errorKind,
$this->_ruleIndex,
$this->_ruleId,
$this->_prerequisiteKey,
$this->_inExperiment,
$bigSegmentEvaluationStatus
);
}

/**
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
* BigSegmentEvaluationStatus}.
*
* 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 bigSegmentEvaluationStatus(): ?BigSegmentEvaluationStatus
{
return $this->_bigSegmentEvaluationStatus;
}

/**
* 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->_bigSegmentEvaluationStatus !== null) {
$ret['bigSegmentsStatus'] = $this->_bigSegmentEvaluationStatus->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\BigSegmentEvaluationStatus;

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

declare(strict_types=1);

namespace LaunchDarkly\Impl\BigSegments;

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

class StoreManager
{
private Types\BigSegmentConfig $config;
private ?Subsystems\BigSegmentStore $store;
private Impl\BigSegments\StoreStatusProvider $statusProvider;
private ?Types\BigSegmentStoreStatus $lastStatus;
private ?DateTimeImmutable $lastStatusPollTime;

public function __construct(Types\BigSegmentConfig $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;
}

$cachedItem = $this->config->cache?->getItem($contextKey);
/** @var ?array */
$membership = $cachedItem?->get();

if ($membership === null) {
try {
$membership = $this->store->getMembership(StoreManager::hashForContextKey($contextKey));
if ($this->config->cache !== null) {
/**
* @psalm-suppress PossiblyNullArgument
*/
$cachedItem = $this->config
->cache
->getItem($contextKey)
->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, BigSegmentEvaluationStatus::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, BigSegmentEvaluationStatus::STORE_ERROR);
}

return new Impl\BigSegments\MembershipResult($membership, $status->isStale() ? BigSegmentEvaluationStatus::STALE : BigSegmentEvaluationStatus::HEALTHY);
}

private function pollAndUpdateStatus(): Types\BigSegmentStoreStatus
{
$newStatus = new Types\BigSegmentStoreStatus(false, false);
if ($this->store !== null) {
try {
$metadata = $this->store->getMetadata();
$newStatus = new Types\BigSegmentStoreStatus(
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\BigSegmentStoreStatus
*/
private $statusFn;
private ?Types\BigSegmentStoreStatus $lastStatus;
private LoggerInterface $logger;

/**
* @param callable(): Types\BigSegmentStoreStatus $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\BigSegmentStoreStatus $status): void
{
if ($this->lastStatus != $status) {
$old = $this->lastStatus;
$this->lastStatus = $status;

$this->notify(old: $old, new: $status);
}
}

private function notify(?Types\BigSegmentStoreStatus $old, Types\BigSegmentStoreStatus $new): void
{
/** @var Subsystems\BigSegmentStatusListener $listener */
foreach ($this->listeners as $listener) {
try {
$listener->statusChanged($old, $new);
} catch (Exception $e) {
$this->logger->warning('A big segment status listener threw an exception', ['exception' => $e->getMessage()]);
}
}
}

public function lastStatus(): ?Types\BigSegmentStoreStatus
{
return $this->lastStatus;
}

public function status(): Types\BigSegmentStoreStatus
{
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

0 comments on commit 62b63ba

Please sign in to comment.