Skip to content

Commit bd6c50c

Browse files
authored
feat: Add support for big segments (#213)
1 parent dd318af commit bd6c50c

38 files changed

+1650
-49
lines changed

.github/actions/ci/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,4 @@ runs:
5555
with:
5656
test_service_port: 8000
5757
token: ${{ inputs.token }}
58-
extra_params: "-skip 'evaluation/parameterized/attribute references/array index is not supported'"
58+
extra_params: "-skip 'evaluation/parameterized/attribute references/array index is not supported' -skip 'big segments/membership caching/context cache eviction (cache size)'"

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ jobs:
2020
- 8080:8080
2121

2222
strategy:
23+
fail-fast: false
2324
matrix:
2425
php-version: [8.1, 8.2]
2526
use-lowest-dependencies: [true, false]

Makefile

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,14 @@ TEMP_TEST_OUTPUT=/tmp/sse-contract-test-service.log
1313
# - "evaluation/parameterized/attribute references/array index is not supported": Due to how PHP
1414
# arrays work, there's no way to disallow an array index lookup without breaking object property
1515
# lookups for properties that are numeric strings.
16+
#
17+
# - "big segments/membership caching/context cache eviction (cache size)": Caching is provided through
18+
# PSR-6 (psr/cache) interface. This interface does not provide a way to limit the cache size. The
19+
# test harness expects the cache to evict items when the cache size is exceeded. This is not possible
20+
# with the current implementation.
1621
TEST_HARNESS_PARAMS := $(TEST_HARNESS_PARAMS) \
17-
-skip 'evaluation/parameterized/attribute references/array index is not supported'
22+
-skip 'evaluation/parameterized/attribute references/array index is not supported' \
23+
-skip 'big segments/membership caching/context cache eviction (cache size)'
1824

1925
build-contract-tests:
2026
@cd test-service && composer install --no-progress

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"require": {
1717
"php": ">=8.1",
1818
"monolog/monolog": "^2.0|^3.0",
19+
"psr/cache": "^3.0",
1920
"psr/log": "^1.0|^2.0|^3.0"
2021
},
2122
"require-dev": {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace LaunchDarkly;
6+
7+
/**
8+
* A status enum which represents the result of a Big Segment query involved in
9+
* a flag evaluation.
10+
*/
11+
enum BigSegmentsEvaluationStatus: string
12+
{
13+
/**
14+
* Indicates that the Big Segment query involved in the flag evaluation was
15+
* successful, and that the segment state is considered up to date.
16+
*/
17+
case HEALTHY = 'HEALTHY';
18+
19+
/**
20+
* Indicates that the Big Segment query involved in the flag evaluation was
21+
* successful, but that the segment state may not be up to date.
22+
*/
23+
case STALE = 'STALE';
24+
25+
/**
26+
* Indicates that Big Segments could not be queried for the flag evaluation
27+
* because the SDK configuration did not include a Big Segment store.
28+
*/
29+
case NOT_CONFIGURED = 'NOT_CONFIGURED';
30+
31+
/**
32+
* Indicates that the Big Segment query involved in the flag evaluation
33+
* failed, for instance due to a database error.
34+
*/
35+
case STORE_ERROR = 'STORE_ERROR';
36+
}

src/LaunchDarkly/EvaluationReason.php

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ class EvaluationReason implements \JsonSerializable
9696
private ?string $_ruleId;
9797
private ?string $_prerequisiteKey;
9898
private bool $_inExperiment;
99+
private ?BigSegmentsEvaluationStatus $_bigSegmentsEvaluationStatus;
99100

100101
/**
101102
* Creates a new instance of the OFF reason.
@@ -164,14 +165,37 @@ private function __construct(
164165
?int $ruleIndex = null,
165166
?string $ruleId = null,
166167
?string $prerequisiteKey = null,
167-
bool $inExperiment = false
168+
bool $inExperiment = false,
169+
BigSegmentsEvaluationStatus $bigSegmentsEvaluationStatus = null
168170
) {
169171
$this->_kind = $kind;
170172
$this->_errorKind = $errorKind;
171173
$this->_ruleIndex = $ruleIndex;
172174
$this->_ruleId = $ruleId;
173175
$this->_prerequisiteKey = $prerequisiteKey;
174176
$this->_inExperiment = $inExperiment;
177+
$this->_bigSegmentsEvaluationStatus = $bigSegmentsEvaluationStatus;
178+
}
179+
180+
/**
181+
* Returns a new EvaluationReason instance matching all the properties of
182+
* this one, except for the big segments evaluation status.
183+
*/
184+
public function withBigSegmentsEvaluationStatus(BigSegmentsEvaluationStatus $bigSegmentsEvaluationStatus): EvaluationReason
185+
{
186+
if ($this->_bigSegmentsEvaluationStatus == $bigSegmentsEvaluationStatus) {
187+
return $this;
188+
}
189+
190+
return new EvaluationReason(
191+
$this->_kind,
192+
$this->_errorKind,
193+
$this->_ruleIndex,
194+
$this->_ruleId,
195+
$this->_prerequisiteKey,
196+
$this->_inExperiment,
197+
$bigSegmentsEvaluationStatus
198+
);
175199
}
176200

177201
/**
@@ -233,6 +257,21 @@ public function isInExperiment(): bool
233257
return $this->_inExperiment;
234258
}
235259

260+
/**
261+
* Describes the validity of Big Segment information, if and only if the
262+
* flag evaluation required querying at least one Big Segment. Otherwise it
263+
* returns null. Possible values are defined by {@see
264+
* BigSegmentsEvaluationStatus}.
265+
*
266+
* Big Segments are a specific kind of context segments. For more
267+
* information, read the LaunchDarkly documentation:
268+
* https://docs.launchdarkly.com/home/users/big-segments
269+
*/
270+
public function bigSegmentsEvaluationStatus(): ?BigSegmentsEvaluationStatus
271+
{
272+
return $this->_bigSegmentsEvaluationStatus;
273+
}
274+
236275
/**
237276
* Returns a simple string representation of this object.
238277
*/
@@ -272,6 +311,9 @@ public function jsonSerialize(): array
272311
if ($this->_inExperiment) {
273312
$ret['inExperiment'] = $this->_inExperiment;
274313
}
314+
if ($this->_bigSegmentsEvaluationStatus !== null) {
315+
$ret['bigSegmentsStatus'] = $this->_bigSegmentsEvaluationStatus->value;
316+
}
275317
return $ret;
276318
}
277319
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace LaunchDarkly\Impl\BigSegments;
6+
7+
use LaunchDarkly\BigSegmentsEvaluationStatus;
8+
9+
class MembershipResult
10+
{
11+
/**
12+
* @param ?array<string, bool> $membership A map from segment reference to
13+
* inclusion status (true if the context is included, false if excluded).
14+
* If null, the membership could not be retrieved.
15+
*/
16+
public function __construct(
17+
public readonly ?array $membership,
18+
public readonly BigSegmentsEvaluationStatus $status
19+
) {
20+
}
21+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace LaunchDarkly\Impl\BigSegments;
6+
7+
use DateTimeImmutable;
8+
use Exception;
9+
use LaunchDarkly\BigSegmentsEvaluationStatus;
10+
use LaunchDarkly\Impl;
11+
use LaunchDarkly\Subsystems;
12+
use LaunchDarkly\Types;
13+
use Psr\Log\LoggerInterface;
14+
15+
class StoreManager
16+
{
17+
private Types\BigSegmentsConfig $config;
18+
private ?Subsystems\BigSegmentsStore $store;
19+
private Impl\BigSegments\StoreStatusProvider $statusProvider;
20+
private ?Types\BigSegmentsStoreStatus $lastStatus;
21+
private ?DateTimeImmutable $lastStatusPollTime;
22+
23+
public function __construct(Types\BigSegmentsConfig $config, private readonly LoggerInterface $logger)
24+
{
25+
$this->config = $config;
26+
$this->store = $config->store;
27+
$this->statusProvider = new Impl\BigSegments\StoreStatusProvider(
28+
fn () => $this->pollAndUpdateStatus(),
29+
$logger
30+
);
31+
$this->lastStatus = null;
32+
$this->lastStatusPollTime = null;
33+
}
34+
35+
public function getStatusProvider(): Subsystems\BigSegmentStatusProvider
36+
{
37+
return $this->statusProvider;
38+
}
39+
40+
/**
41+
* Retrieves the membership of a context key in a Big Segment, if a backing
42+
* big segments store has been configured.
43+
*/
44+
public function getContextMembership(string $contextKey): ?Impl\BigSegments\MembershipResult
45+
{
46+
if ($this->store === null) {
47+
return null;
48+
}
49+
50+
$cachedItem = null;
51+
try {
52+
$cachedItem = $this->config->cache?->getItem($contextKey);
53+
} catch (Exception $e) {
54+
$this->logger->warning("Failed to retrieve cached item for big segment", ['contextKey' => $contextKey, 'exception' => $e->getMessage()]);
55+
}
56+
/** @var ?array<string, bool> */
57+
$membership = $cachedItem?->get();
58+
59+
if ($membership === null) {
60+
try {
61+
$membership = $this->store->getMembership(StoreManager::hashForContextKey($contextKey));
62+
if ($this->config->cache !== null && $cachedItem !== null) {
63+
$cachedItem->set($membership)->expiresAfter($this->config->contextCacheTime);
64+
65+
if (!$this->config->cache->save($cachedItem)) {
66+
$this->logger->warning("Failed to save Big Segment membership to cache", ['contextKey' => $contextKey]);
67+
}
68+
}
69+
} catch (Exception $e) {
70+
$this->logger->warning("Failed to retrieve Big Segment membership", ['contextKey' => $contextKey, 'exception' => $e->getMessage()]);
71+
return new Impl\BigSegments\MembershipResult(null, BigSegmentsEvaluationStatus::STORE_ERROR);
72+
}
73+
}
74+
75+
$nextPollingTime = ($this->lastStatusPollTime?->getTimestamp() ?? 0) + $this->config->statusPollInterval;
76+
77+
$status = $this->lastStatus;
78+
if ($this->lastStatusPollTime === null || $nextPollingTime < time()) {
79+
$status = $this->pollAndUpdateStatus();
80+
}
81+
82+
if ($status === null || !$status->isAvailable()) {
83+
return new Impl\BigSegments\MembershipResult($membership, BigSegmentsEvaluationStatus::STORE_ERROR);
84+
}
85+
86+
return new Impl\BigSegments\MembershipResult($membership, $status->isStale() ? BigSegmentsEvaluationStatus::STALE : BigSegmentsEvaluationStatus::HEALTHY);
87+
}
88+
89+
private function pollAndUpdateStatus(): Types\BigSegmentsStoreStatus
90+
{
91+
$newStatus = new Types\BigSegmentsStoreStatus(false, false);
92+
if ($this->store !== null) {
93+
try {
94+
$metadata = $this->store->getMetadata();
95+
$newStatus = new Types\BigSegmentsStoreStatus(
96+
available: true,
97+
stale: $metadata->isStale($this->config->staleAfter)
98+
);
99+
} catch (Exception $e) {
100+
$this->logger->warning("Failed to retrieve Big Segment metadata", ['exception' => $e->getMessage()]);
101+
}
102+
}
103+
104+
$this->lastStatus = $newStatus;
105+
$this->statusProvider->updateStatus($newStatus);
106+
$this->lastStatusPollTime = new DateTimeImmutable();
107+
108+
return $newStatus;
109+
}
110+
111+
private static function hashForContextKey(string $contextKey): string
112+
{
113+
return base64_encode(hash('sha256', $contextKey, true));
114+
}
115+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace LaunchDarkly\Impl\BigSegments;
6+
7+
use Exception;
8+
use LaunchDarkly\Subsystems;
9+
use LaunchDarkly\Types;
10+
use Psr\Log\LoggerInterface;
11+
use SplObjectStorage;
12+
13+
class StoreStatusProvider implements Subsystems\BigSegmentStatusProvider
14+
{
15+
private SplObjectStorage $listeners;
16+
/**
17+
* @var callable(): Types\BigSegmentsStoreStatus
18+
*/
19+
private $statusFn;
20+
private ?Types\BigSegmentsStoreStatus $lastStatus;
21+
private LoggerInterface $logger;
22+
23+
/**
24+
* @param callable(): Types\BigSegmentsStoreStatus $statusFn
25+
*/
26+
public function __construct(callable $statusFn, LoggerInterface $logger)
27+
{
28+
$this->listeners = new SplObjectStorage();
29+
$this->statusFn = $statusFn;
30+
$this->lastStatus = null;
31+
$this->logger = $logger;
32+
}
33+
34+
public function attach(Subsystems\BigSegmentStatusListener $listener): void
35+
{
36+
$this->listeners->attach($listener);
37+
}
38+
39+
public function detach(Subsystems\BigSegmentStatusListener $listener): void
40+
{
41+
$this->listeners->detach($listener);
42+
}
43+
44+
/**
45+
* @internal
46+
*/
47+
public function updateStatus(Types\BigSegmentsStoreStatus $status): void
48+
{
49+
if ($this->lastStatus != $status) {
50+
$old = $this->lastStatus;
51+
$this->lastStatus = $status;
52+
53+
$this->notify(old: $old, new: $status);
54+
}
55+
}
56+
57+
private function notify(?Types\BigSegmentsStoreStatus $old, Types\BigSegmentsStoreStatus $new): void
58+
{
59+
/** @var Subsystems\BigSegmentStatusListener $listener */
60+
foreach ($this->listeners as $listener) {
61+
try {
62+
$listener->statusChanged($old, $new);
63+
} catch (Exception $e) {
64+
$this->logger->warning('A big segments status listener threw an exception', ['exception' => $e->getMessage()]);
65+
}
66+
}
67+
}
68+
69+
public function lastStatus(): ?Types\BigSegmentsStoreStatus
70+
{
71+
return $this->lastStatus;
72+
}
73+
74+
public function status(): Types\BigSegmentsStoreStatus
75+
{
76+
return ($this->statusFn)();
77+
}
78+
}

src/LaunchDarkly/Impl/Evaluation/EvalResult.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ public function withState(EvaluatorState $state): EvalResult
3434
return new EvalResult($this->_detail, $this->_forceReasonTracking, $state);
3535
}
3636

37+
public function withDetail(EvaluationDetail $detail): EvalResult
38+
{
39+
return new EvalResult($detail, $this->_forceReasonTracking, $this->_state);
40+
}
41+
3742
public function getDetail(): EvaluationDetail
3843
{
3944
return $this->_detail;

0 commit comments

Comments
 (0)