From a7d69a3c9c696f46c0ae1b791049512bed093130 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Tue, 14 Jan 2025 10:05:04 -0500 Subject: [PATCH] feat: Add Big Segment store support Release-As: 2.0.0 --- composer.json | 3 +- .../Integrations/PHPRedisBigSegmentsStore.php | 80 +++++++++++++++++ .../Integrations/PHPRedisFeatureRequester.php | 3 +- src/LaunchDarkly/Integrations/PHPRedis.php | 25 +++++- .../PHPRedisBigSegmentsStoreTest.php | 88 +++++++++++++++++++ 5 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 src/LaunchDarkly/Impl/Integrations/PHPRedisBigSegmentsStore.php create mode 100644 tests/Impl/Integrations/PHPRedisBigSegmentsStoreTest.php diff --git a/composer.json b/composer.json index faacad4..b1f551d 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,8 @@ "require": { "php": ">=8.1", "ext-redis": "*", - "launchdarkly/server-sdk": ">=6.3.0 <7.0.0" + "launchdarkly/server-sdk": ">=6.4.0 <7.0.0", + "psr/log": "^3.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.68", diff --git a/src/LaunchDarkly/Impl/Integrations/PHPRedisBigSegmentsStore.php b/src/LaunchDarkly/Impl/Integrations/PHPRedisBigSegmentsStore.php new file mode 100644 index 0000000..86ee9dc --- /dev/null +++ b/src/LaunchDarkly/Impl/Integrations/PHPRedisBigSegmentsStore.php @@ -0,0 +1,80 @@ + $options + * - `prefix`: namespace prefix to add to all hash keys + */ + public function __construct( + private readonly Redis $connection, + private readonly LoggerInterface $logger, + readonly array $options = [] + ) { + /** @var string */ + $this->prefix = $options['prefix'] ?? Integrations\PHPRedis::DEFAULT_PREFIX; + } + + public function getMetadata(): Types\BigSegmentsStoreMetadata + { + try { + /** @var string|false */ + $lastUpToDate = $this->connection->get($this->prefix . self::KEY_LAST_UP_TO_DATE); + } catch (Exception $e) { + $this->logger->warning('Error getting last-up-to-date time from Redis', ['exception' => $e->getMessage()]); + return new Types\BigSegmentsStoreMetadata(lastUpToDate: null); + } + + if ($lastUpToDate === false) { + $lastUpToDate = null; + } else { + $lastUpToDate = (int)$lastUpToDate; + } + + return new Types\BigSegmentsStoreMetadata(lastUpToDate: $lastUpToDate); + } + + public function getMembership(string $contextHash): ?array + { + try { + /** @var array */ + $includeRefs = $this->connection->sMembers($this->prefix . self::KEY_CONTEXT_INCLUDE . $contextHash); + /** @var array */ + $excludeRefs = $this->connection->sMembers($this->prefix . self::KEY_CONTEXT_EXCLUDE . $contextHash); + } catch (Exception $e) { + $this->logger->warning('Error getting big segments membership from Redis', ['exception' => $e->getMessage()]); + return null; + } + + $membership = []; + foreach ($excludeRefs as $ref) { + $membership[$ref] = false; + } + + foreach ($includeRefs as $ref) { + $membership[$ref] = true; + } + + return $membership; + } +} diff --git a/src/LaunchDarkly/Impl/Integrations/PHPRedisFeatureRequester.php b/src/LaunchDarkly/Impl/Integrations/PHPRedisFeatureRequester.php index fcdd0e6..8b957c1 100644 --- a/src/LaunchDarkly/Impl/Integrations/PHPRedisFeatureRequester.php +++ b/src/LaunchDarkly/Impl/Integrations/PHPRedisFeatureRequester.php @@ -2,6 +2,7 @@ namespace LaunchDarkly\Impl\Integrations; +use LaunchDarkly\Integrations; use Redis; /** @@ -20,7 +21,7 @@ public function __construct(string $baseUri, string $sdkKey, array $options) /** @var ?string **/ $this->prefix = $options['redis_prefix'] ?? null; if ($this->prefix === null || $this->prefix === '') { - $this->prefix = 'launchdarkly'; + $this->prefix = Integrations\PHPRedis::DEFAULT_PREFIX; } /** @var ?Redis */ diff --git a/src/LaunchDarkly/Integrations/PHPRedis.php b/src/LaunchDarkly/Integrations/PHPRedis.php index 547a896..cfc45ba 100644 --- a/src/LaunchDarkly/Integrations/PHPRedis.php +++ b/src/LaunchDarkly/Integrations/PHPRedis.php @@ -2,13 +2,18 @@ namespace LaunchDarkly\Integrations; -use LaunchDarkly\Impl\Integrations\PHPRedisFeatureRequester; +use LaunchDarkly\Impl\Integrations; +use LaunchDarkly\Subsystems; +use Psr\Log\LoggerInterface; +use Redis; /** * Integration with a Redis data store using the `phpredis` extension. */ class PHPRedis { + const DEFAULT_PREFIX = 'launchdarkly'; + /** * Configures an adapter for reading feature flag data from Redis using persistent connections. * @@ -40,7 +45,23 @@ public static function featureRequester($options = []) } return function (string $baseUri, string $sdkKey, array $baseOptions) use ($options) { - return new PHPRedisFeatureRequester($baseUri, $sdkKey, array_merge($baseOptions, $options)); + return new Integrations\PHPRedisFeatureRequester($baseUri, $sdkKey, array_merge($baseOptions, $options)); + }; + } + + /** + * @param array $options + * - `prefix`: namespace prefix to add to all hash keys + * @return callable(LoggerInterface, array): Subsystems\BigSegmentsStore + */ + public static function bigSegmentsStore(Redis $client, array $options = []): callable + { + if (!extension_loaded('redis')) { + throw new \RuntimeException("phpredis extension is required to use Integrations\\PHPRedis"); + } + + return function (LoggerInterface $logger, array $baseOptions) use ($client, $options): Subsystems\BigSegmentsStore { + return new Integrations\PHPRedisBigSegmentsStore($client, $logger, array_merge($baseOptions, $options)); }; } } diff --git a/tests/Impl/Integrations/PHPRedisBigSegmentsStoreTest.php b/tests/Impl/Integrations/PHPRedisBigSegmentsStoreTest.php new file mode 100644 index 0000000..71c6c8a --- /dev/null +++ b/tests/Impl/Integrations/PHPRedisBigSegmentsStoreTest.php @@ -0,0 +1,88 @@ +flushAll(); + $store = new PHPRedisBigSegmentsStore($connection, $logger, []); + + $metadata = $store->getMetadata(); + $this->assertNull($metadata->getLastUpToDate()); + $this->assertTrue($metadata->isStale(10)); + + $connection->set('launchdarkly:big_segments_synchronized_on', $now); + $metadata = $store->getMetadata(); + $this->assertEquals($now, $metadata->getLastUpToDate()); + $this->assertFalse($metadata->isStale(10)); + } + + public function testGetMetadataWithInvalidConfiguration(): void + { + $logger = new Log\NullLogger(); + + $connection = new Redis(['port' => 33_333, 'connectTimeout' => 1]); + $store = new PHPRedisBigSegmentsStore($connection, $logger, []); + + $metadata = $store->getMetadata(); + + $this->assertNull($metadata->getLastUpToDate()); + $this->assertTrue($metadata->isStale(10)); + } + + public function testCanDetectInclusion(): void + { + $logger = new Log\NullLogger(); + + $connection = new Redis(); + $connection->flushAll(); + $connection->sAdd('launchdarkly:big_segment_include:ctx', 'key1', 'key2'); + $connection->sAdd('launchdarkly:big_segment_exclude:ctx', 'key1', 'key3'); + + $store = new PHPRedisBigSegmentsStore($connection, $logger, []); + + $membership = $store->getMembership('ctx') ?? []; + + $this->assertCount(3, $membership); + $this->assertTrue($membership['key1']); + $this->assertTrue($membership['key2']); + $this->assertFalse($membership['key3']); + } + + public function testCanDetectInclusionWithEmptyData(): void + { + $logger = new Log\NullLogger(); + + $connection = new Redis(); + $connection->flushAll(); + + $store = new PHPRedisBigSegmentsStore($connection, $logger, []); + + $membership = $store->getMembership('ctx'); + + $this->assertNotNull($membership); + $this->assertCount(0, $membership); + } + + public function testCanDetectInclusionWithInvalidConfiguration(): void + { + $logger = new Log\NullLogger(); + + $connection = new Redis(['port' => 33_333, 'connectTimeout' => 1]); + $store = new PHPRedisBigSegmentsStore($connection, $logger, []); + $membership = $store->getMembership('ctx'); + + $this->assertNull($membership); + } +}