diff --git a/libs/server-sdk-dynamodb-source/include/launchdarkly/server_side/integrations/dynamodb/dynamodb_big_segment_store.hpp b/libs/server-sdk-dynamodb-source/include/launchdarkly/server_side/integrations/dynamodb/dynamodb_big_segment_store.hpp new file mode 100644 index 000000000..2a5e80f61 --- /dev/null +++ b/libs/server-sdk-dynamodb-source/include/launchdarkly/server_side/integrations/dynamodb/dynamodb_big_segment_store.hpp @@ -0,0 +1,82 @@ +/** @file dynamodb_big_segment_store.hpp + * @brief Server-Side DynamoDB Big Segments Store + */ + +#pragma once + +#include +#include + +#include + +#include +#include + +namespace Aws::DynamoDB { +class DynamoDBClient; +} + +namespace launchdarkly::server_side::integrations { + +/** + * @brief DynamoDBBigSegmentStore is a Big Segments persistent store backed by + * Amazon DynamoDB. + * + * Call DynamoDBBigSegmentStore::Create to obtain a new instance, then pass it + * to the SDK via the Big Segments config builder. + * + * The DynamoDB table must already exist and follow the LaunchDarkly schema: + * a String partition key named `namespace` and a String sort key named `key`. + * The same table can be shared with @ref DynamoDBDataSource — Big Segments + * rows occupy their own partition-key values and do not conflict with + * flag/segment rows. The LaunchDarkly Relay Proxy is responsible for + * populating Big Segments data in this table; this class only reads from it. + * + * This implementation is backed by the AWS SDK for C++. + */ +class DynamoDBBigSegmentStore final : public IBigSegmentStore { + public: + /** + * @brief Creates a new DynamoDBBigSegmentStore, or returns an error if + * construction failed. + * + * @param table_name Name of the DynamoDB table to read from. The table + * must already exist; this class does not create it. + * + * @param prefix Optional namespace prefix. When non-empty, Big Segments + * rows live under partition keys `:big_segments_user` and + * `:big_segments_metadata`. This allows multiple LaunchDarkly + * environments to share a single table. + * + * @param options Optional AWS DynamoDB client configuration. See + * @ref DynamoDBClientOptions. When defaulted, the AWS SDK resolves + * region, endpoint, and credentials from the standard provider chain + * (environment variables, shared config files, instance metadata). + * + * @return A DynamoDBBigSegmentStore, or an error if construction failed. + */ + static tl::expected, std::string> + Create(std::string table_name, + std::string prefix, + DynamoDBClientOptions options = {}); + + [[nodiscard]] GetMembershipResult GetMembership( + std::string const& context_hash) const override; + [[nodiscard]] GetMetadataResult GetMetadata() const override; + + ~DynamoDBBigSegmentStore() override; + + private: + DynamoDBBigSegmentStore( + std::unique_ptr client, + std::string table_name, + std::string prefix); + + std::unique_ptr client_; + std::string const table_name_; + std::string const prefix_; + std::string const user_namespace_; + std::string const metadata_namespace_; +}; + +} // namespace launchdarkly::server_side::integrations diff --git a/libs/server-sdk-dynamodb-source/src/CMakeLists.txt b/libs/server-sdk-dynamodb-source/src/CMakeLists.txt index af94ae3b4..02f954b07 100644 --- a/libs/server-sdk-dynamodb-source/src/CMakeLists.txt +++ b/libs/server-sdk-dynamodb-source/src/CMakeLists.txt @@ -14,6 +14,7 @@ target_sources(${LIBNAME} PRIVATE ${HEADER_LIST} dynamodb_source.cpp + dynamodb_big_segment_store.cpp aws_sdk_guard.cpp client_factory.cpp ) diff --git a/libs/server-sdk-dynamodb-source/src/dynamodb_attributes.hpp b/libs/server-sdk-dynamodb-source/src/dynamodb_attributes.hpp index 84ffbc31a..fc531f3e9 100644 --- a/libs/server-sdk-dynamodb-source/src/dynamodb_attributes.hpp +++ b/libs/server-sdk-dynamodb-source/src/dynamodb_attributes.hpp @@ -18,4 +18,16 @@ inline constexpr char kItemAttribute[] = "item"; // {namespace: "myprefix:$inited", key: "myprefix:$inited"}. inline constexpr char kInitedNamespace[] = "$inited"; +// Big Segments schema. Membership rows use partition key +// "{prefix}:big_segments_user" and sort key {context_hash}, with +// "included" / "excluded" String Set attributes naming segment refs. The +// metadata row uses partition key AND sort key both set to +// "{prefix}:big_segments_metadata", with the sync timestamp stored as a +// Number under "synchronizedOn". +inline constexpr char kBigSegmentsUserNamespace[] = "big_segments_user"; +inline constexpr char kBigSegmentsMetadataNamespace[] = "big_segments_metadata"; +inline constexpr char kBigSegmentsIncludedAttribute[] = "included"; +inline constexpr char kBigSegmentsExcludedAttribute[] = "excluded"; +inline constexpr char kBigSegmentsSyncTimeAttribute[] = "synchronizedOn"; + } // namespace launchdarkly::server_side::integrations::detail diff --git a/libs/server-sdk-dynamodb-source/src/dynamodb_big_segment_store.cpp b/libs/server-sdk-dynamodb-source/src/dynamodb_big_segment_store.cpp new file mode 100644 index 000000000..53010ad4b --- /dev/null +++ b/libs/server-sdk-dynamodb-source/src/dynamodb_big_segment_store.cpp @@ -0,0 +1,148 @@ +#include + +#include "aws_sdk_guard.hpp" +#include "client_factory.hpp" +#include "dynamodb_attributes.hpp" +#include "prefix.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace launchdarkly::server_side::integrations { + +namespace { + +using detail::kBigSegmentsExcludedAttribute; +using detail::kBigSegmentsIncludedAttribute; +using detail::kBigSegmentsMetadataNamespace; +using detail::kBigSegmentsSyncTimeAttribute; +using detail::kBigSegmentsUserNamespace; +using detail::kPartitionKey; +using detail::kSortKey; +using detail::PrefixedNamespace; + +} // namespace + +tl::expected, std::string> +DynamoDBBigSegmentStore::Create(std::string table_name, + std::string prefix, + DynamoDBClientOptions options) { + try { + detail::AwsSdkGuard::Ensure(); + auto maybe_client = detail::BuildDynamoDBClient(options); + if (!maybe_client) { + return tl::make_unexpected(std::move(maybe_client.error())); + } + return std::unique_ptr( + new DynamoDBBigSegmentStore(std::move(*maybe_client), + std::move(table_name), + std::move(prefix))); + } catch (std::exception const& e) { + return tl::make_unexpected(e.what()); + } +} + +DynamoDBBigSegmentStore::DynamoDBBigSegmentStore( + std::unique_ptr client, + std::string table_name, + std::string prefix) + : client_(std::move(client)), + table_name_(std::move(table_name)), + prefix_(std::move(prefix)), + user_namespace_(PrefixedNamespace(prefix_, kBigSegmentsUserNamespace)), + metadata_namespace_( + PrefixedNamespace(prefix_, kBigSegmentsMetadataNamespace)) {} + +DynamoDBBigSegmentStore::~DynamoDBBigSegmentStore() = default; + +IBigSegmentStore::GetMembershipResult DynamoDBBigSegmentStore::GetMembership( + std::string const& context_hash) const { + Aws::DynamoDB::Model::GetItemRequest request; + request.SetTableName(table_name_); + request.SetConsistentRead(true); + request.AddKey(kPartitionKey, + Aws::DynamoDB::Model::AttributeValue{user_namespace_}); + request.AddKey(kSortKey, + Aws::DynamoDB::Model::AttributeValue{context_hash}); + + auto outcome = client_->GetItem(request); + if (!outcome.IsSuccess()) { + return tl::make_unexpected(outcome.GetError().GetMessage()); + } + + auto const& item = outcome.GetResult().GetItem(); + if (item.empty()) { + return std::nullopt; + } + + std::vector included; + std::vector excluded; + + if (auto const it = item.find(kBigSegmentsIncludedAttribute); + it != item.end()) { + for (auto const& ref : it->second.GetSS()) { + included.emplace_back(ref); + } + } + if (auto const it = item.find(kBigSegmentsExcludedAttribute); + it != item.end()) { + for (auto const& ref : it->second.GetSS()) { + excluded.emplace_back(ref); + } + } + + return Membership::FromSegmentRefs(included, excluded); +} + +IBigSegmentStore::GetMetadataResult DynamoDBBigSegmentStore::GetMetadata() + const { + Aws::DynamoDB::Model::GetItemRequest request; + request.SetTableName(table_name_); + request.SetConsistentRead(true); + request.AddKey(kPartitionKey, + Aws::DynamoDB::Model::AttributeValue{metadata_namespace_}); + request.AddKey(kSortKey, + Aws::DynamoDB::Model::AttributeValue{metadata_namespace_}); + + auto outcome = client_->GetItem(request); + if (!outcome.IsSuccess()) { + return tl::make_unexpected(outcome.GetError().GetMessage()); + } + + auto const& item = outcome.GetResult().GetItem(); + if (item.empty()) { + return std::nullopt; + } + + auto const it = item.find(kBigSegmentsSyncTimeAttribute); + if (it == item.end()) { + return tl::make_unexpected( + "DynamoDB Big Segments metadata row missing 'synchronizedOn'"); + } + + auto const& raw = it->second.GetN(); + if (raw.empty()) { + return tl::make_unexpected( + "DynamoDB Big Segments 'synchronizedOn' is empty or not type N"); + } + + errno = 0; + char* end = nullptr; + long long const parsed = std::strtoll(raw.c_str(), &end, 10); + if (errno != 0 || end == raw.c_str() || *end != '\0') { + return tl::make_unexpected( + "DynamoDB Big Segments 'synchronizedOn' is not a valid integer"); + } + + return StoreMetadata{std::chrono::milliseconds{parsed}}; +} + +} // namespace launchdarkly::server_side::integrations diff --git a/libs/server-sdk-dynamodb-source/tests/dynamodb_big_segment_store_test.cpp b/libs/server-sdk-dynamodb-source/tests/dynamodb_big_segment_store_test.cpp new file mode 100644 index 000000000..d9bc0c5b8 --- /dev/null +++ b/libs/server-sdk-dynamodb-source/tests/dynamodb_big_segment_store_test.cpp @@ -0,0 +1,160 @@ +#include + +#include + +#include "aws_sdk_guard.hpp" +#include "prefixed_dynamodb_client.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include + +using namespace launchdarkly::server_side::integrations; + +namespace { + +std::string EnvOr(char const* name, std::string const& fallback) { + char const* value = std::getenv(name); + if (value && *value) { + return value; + } + return fallback; +} + +DynamoDBClientOptions LocalOptions() { + DynamoDBClientOptions options; + options.endpoint = + EnvOr("LD_DYNAMODB_TEST_ENDPOINT", "http://localhost:8000"); + options.region = EnvOr("LD_DYNAMODB_TEST_REGION", "us-east-1"); + options.aws_access_key_id = "dummy"; + options.aws_secret_access_key = "dummy"; + return options; +} + +class DynamoDBBigSegmentTests : public ::testing::Test { + public: + DynamoDBBigSegmentTests() + : table_name_("ld-dynamodb-big-segments-test"), + prefix_("testprefix"), + options_(LocalOptions()), + client_(MakeRawClient()) {} + + void SetUp() override { + PrefixedDynamoDBClient::DeleteTable(*client_, table_name_); + PrefixedDynamoDBClient::CreateTable(*client_, table_name_); + + auto maybe_store = + DynamoDBBigSegmentStore::Create(table_name_, prefix_, options_); + ASSERT_TRUE(maybe_store) << maybe_store.error(); + store_ = std::move(*maybe_store); + } + + void TearDown() override { + store_.reset(); + PrefixedDynamoDBClient::DeleteTable(*client_, table_name_); + } + + protected: + std::unique_ptr store_; + std::string const table_name_; + std::string const prefix_; + DynamoDBClientOptions const options_; + std::unique_ptr client_; + + private: + std::unique_ptr MakeRawClient() const { + detail::AwsSdkGuard::Ensure(); + Aws::Client::ClientConfiguration config; + config.region = *options_.region; + config.endpointOverride = *options_.endpoint; + if (options_.endpoint->rfind("http://", 0) == 0) { + config.scheme = Aws::Http::Scheme::HTTP; + config.verifySSL = false; + } + Aws::Auth::AWSCredentials creds{*options_.aws_access_key_id, + *options_.aws_secret_access_key}; + return std::make_unique(creds, config); + } +}; + +} // namespace + +TEST_F(DynamoDBBigSegmentTests, EmptyStoreReturnsNoMembership) { + auto const result = store_->GetMembership("nobody"); + ASSERT_TRUE(result); + ASSERT_FALSE(result->has_value()); +} + +TEST_F(DynamoDBBigSegmentTests, EmptyStoreReturnsNoMetadata) { + auto const result = store_->GetMetadata(); + ASSERT_TRUE(result); + ASSERT_FALSE(result->has_value()); +} + +TEST_F(DynamoDBBigSegmentTests, GetMembershipWithIncludesOnly) { + PrefixedDynamoDBClient(*client_, prefix_, table_name_) + .PutBigSegmentMembership("alice", {"seg1.g1", "seg2.g3"}, {}); + + auto const result = store_->GetMembership("alice"); + ASSERT_TRUE(result); + ASSERT_TRUE(result->has_value()); + + auto const& m = result->value(); + ASSERT_EQ(m.CheckMembership("seg1.g1"), true); + ASSERT_EQ(m.CheckMembership("seg2.g3"), true); + ASSERT_FALSE(m.CheckMembership("seg3.g1").has_value()); +} + +TEST_F(DynamoDBBigSegmentTests, GetMembershipWithExcludesOnly) { + PrefixedDynamoDBClient(*client_, prefix_, table_name_) + .PutBigSegmentMembership("bob", {}, {"seg1.g1"}); + + auto const result = store_->GetMembership("bob"); + ASSERT_TRUE(result); + ASSERT_TRUE(result->has_value()); + ASSERT_EQ(result->value().CheckMembership("seg1.g1"), false); +} + +TEST_F(DynamoDBBigSegmentTests, GetMembershipInclusionWinsOverExclusion) { + PrefixedDynamoDBClient(*client_, prefix_, table_name_) + .PutBigSegmentMembership("carol", {"seg.g1"}, {"seg.g1"}); + + auto const result = store_->GetMembership("carol"); + ASSERT_TRUE(result); + ASSERT_TRUE(result->has_value()); + ASSERT_EQ(result->value().CheckMembership("seg.g1"), true); +} + +TEST_F(DynamoDBBigSegmentTests, GetMembershipIsPrefixScoped) { + PrefixedDynamoDBClient(*client_, "otherprefix", table_name_) + .PutBigSegmentMembership("alice", {"seg1.g1"}, {}); + + auto const result = store_->GetMembership("alice"); + ASSERT_TRUE(result); + ASSERT_FALSE(result->has_value()); +} + +TEST_F(DynamoDBBigSegmentTests, GetMetadataReturnsSyncTime) { + PrefixedDynamoDBClient(*client_, prefix_, table_name_) + .PutBigSegmentSyncTime(1700000000000LL); + + auto const result = store_->GetMetadata(); + ASSERT_TRUE(result); + ASSERT_TRUE(result->has_value()); + ASSERT_EQ(result->value().last_up_to_date, + std::chrono::milliseconds{1700000000000LL}); +} + +TEST_F(DynamoDBBigSegmentTests, GetMetadataRejectsMalformedSyncTime) { + PrefixedDynamoDBClient(*client_, prefix_, table_name_) + .PutMalformedBigSegmentSyncTime(); + + auto const result = store_->GetMetadata(); + ASSERT_FALSE(result); +} diff --git a/libs/server-sdk-dynamodb-source/tests/prefixed_dynamodb_client.hpp b/libs/server-sdk-dynamodb-source/tests/prefixed_dynamodb_client.hpp index 1a1b9cc71..4765fb893 100644 --- a/libs/server-sdk-dynamodb-source/tests/prefixed_dynamodb_client.hpp +++ b/libs/server-sdk-dynamodb-source/tests/prefixed_dynamodb_client.hpp @@ -21,7 +21,9 @@ #include +#include #include +#include // PrefixedDynamoDBClient is a test fixture helper that writes flags and // segments directly into a DynamoDB table using the LaunchDarkly schema @@ -58,8 +60,7 @@ class PrefixedDynamoDBClient { Aws::DynamoDB::Model::AttributeDefinition sort_def; sort_def.SetAttributeName("key"); - sort_def.SetAttributeType( - Aws::DynamoDB::Model::ScalarAttributeType::S); + sort_def.SetAttributeType(Aws::DynamoDB::Model::ScalarAttributeType::S); request.AddAttributeDefinitions(sort_def); Aws::DynamoDB::Model::ProvisionedThroughput throughput; @@ -149,7 +150,73 @@ class PrefixedDynamoDBClient { auto outcome = client_.PutItem(request); if (!outcome.IsSuccess()) { FAIL() << "couldn't put DynamoDB item ns=" << Prefixed(ns_suffix) - << " key=" << key << ": " + << " key=" << key << ": " << outcome.GetError().GetMessage(); + } + } + + void PutBigSegmentMembership( + std::string const& context_hash, + std::vector const& included, + std::vector const& excluded) const { + Aws::DynamoDB::Model::PutItemRequest request; + request.SetTableName(table_name_); + request.AddItem("namespace", Aws::DynamoDB::Model::AttributeValue{ + Prefixed("big_segments_user")}); + request.AddItem("key", + Aws::DynamoDB::Model::AttributeValue{context_hash}); + if (!included.empty()) { + Aws::DynamoDB::Model::AttributeValue value; + value.SetSS( + Aws::Vector(included.begin(), included.end())); + request.AddItem("included", value); + } + if (!excluded.empty()) { + Aws::DynamoDB::Model::AttributeValue value; + value.SetSS( + Aws::Vector(excluded.begin(), excluded.end())); + request.AddItem("excluded", value); + } + auto outcome = client_.PutItem(request); + if (!outcome.IsSuccess()) { + FAIL() << "couldn't put DynamoDB big-segments membership: " + << outcome.GetError().GetMessage(); + } + } + + void PutBigSegmentSyncTime(std::int64_t millis) const { + std::string const ns = Prefixed("big_segments_metadata"); + Aws::DynamoDB::Model::PutItemRequest request; + request.SetTableName(table_name_); + request.AddItem("namespace", Aws::DynamoDB::Model::AttributeValue{ns}); + request.AddItem("key", Aws::DynamoDB::Model::AttributeValue{ns}); + + Aws::DynamoDB::Model::AttributeValue value; + value.SetN(std::to_string(millis)); + request.AddItem("synchronizedOn", value); + + auto outcome = client_.PutItem(request); + if (!outcome.IsSuccess()) { + FAIL() << "couldn't put DynamoDB big-segments metadata: " + << outcome.GetError().GetMessage(); + } + } + + // Writes a metadata row whose `synchronizedOn` is stored as a String + // instead of a Number. DynamoDB does not enforce non-key attribute types, + // so a non-Relay writer can produce this shape; we want the store to + // surface it as an error rather than silently returning 0. + void PutMalformedBigSegmentSyncTime() const { + std::string const ns = Prefixed("big_segments_metadata"); + Aws::DynamoDB::Model::PutItemRequest request; + request.SetTableName(table_name_); + request.AddItem("namespace", Aws::DynamoDB::Model::AttributeValue{ns}); + request.AddItem("key", Aws::DynamoDB::Model::AttributeValue{ns}); + request.AddItem("synchronizedOn", + Aws::DynamoDB::Model::AttributeValue{"not-a-number"}); + + auto outcome = client_.PutItem(request); + if (!outcome.IsSuccess()) { + FAIL() << "couldn't put malformed DynamoDB big-segments metadata: " << outcome.GetError().GetMessage(); } } @@ -170,14 +237,13 @@ class PrefixedDynamoDBClient { request.AddItem("namespace", Aws::DynamoDB::Model::AttributeValue{ns}); request.AddItem("key", Aws::DynamoDB::Model::AttributeValue{key}); if (item_attribute) { - request.AddItem("item", - Aws::DynamoDB::Model::AttributeValue{*item_attribute}); + request.AddItem( + "item", Aws::DynamoDB::Model::AttributeValue{*item_attribute}); } auto outcome = client_.PutItem(request); if (!outcome.IsSuccess()) { - FAIL() << "couldn't put DynamoDB item ns=" << ns - << " key=" << key << ": " - << outcome.GetError().GetMessage(); + FAIL() << "couldn't put DynamoDB item ns=" << ns << " key=" << key + << ": " << outcome.GetError().GetMessage(); } } diff --git a/libs/server-sdk-redis-source/include/launchdarkly/server_side/integrations/redis/redis_big_segment_store.hpp b/libs/server-sdk-redis-source/include/launchdarkly/server_side/integrations/redis/redis_big_segment_store.hpp new file mode 100644 index 000000000..978e6266d --- /dev/null +++ b/libs/server-sdk-redis-source/include/launchdarkly/server_side/integrations/redis/redis_big_segment_store.hpp @@ -0,0 +1,72 @@ +/** @file redis_big_segment_store.hpp + * @brief Server-Side Redis Big Segments Store + */ + +#pragma once + +#include + +#include + +#include +#include + +namespace sw::redis { +class Redis; +} + +namespace launchdarkly::server_side::integrations { + +/** + * @brief RedisBigSegmentStore is a Big Segments persistent store backed by + * Redis. + * + * Call RedisBigSegmentStore::Create to obtain a new instance, then pass it to + * the SDK via the Big Segments config builder. + * + * The same Redis database can be shared with @ref RedisDataSource — Big + * Segments keys use their own prefixed namespaces (`big_segment_include`, + * `big_segment_exclude`, `big_segments_synchronized_on`) and do not conflict + * with the flag/segment hashes. The LaunchDarkly Relay Proxy is responsible + * for populating Big Segments data in Redis; this class only reads from it. + * + * This implementation is backed by Redis++, a C++ wrapper + * for the hiredis library. + */ +class RedisBigSegmentStore final : public IBigSegmentStore { + public: + /** + * @brief Creates a new RedisBigSegmentStore, or returns an error if + * construction failed. + * + * @param uri Redis URI. The URI is passed to the underlying Redis++ client + * verbatim. See Redis++ + * API Reference for details on the possible URI formats. + * + * @param prefix Prefix to use when reading SDK data from Redis. This + * allows multiple LaunchDarkly environments to be stored in the same + * database (under different prefixes). + * + * @return A RedisBigSegmentStore, or an error if construction failed. + */ + static tl::expected, std::string> + Create(std::string uri, std::string prefix); + + [[nodiscard]] GetMembershipResult GetMembership( + std::string const& context_hash) const override; + [[nodiscard]] GetMetadataResult GetMetadata() const override; + + ~RedisBigSegmentStore() override; + + private: + RedisBigSegmentStore(std::unique_ptr redis, + std::string prefix); + + std::unique_ptr redis_; + std::string const prefix_; + std::string const sync_time_key_; +}; + +} // namespace launchdarkly::server_side::integrations diff --git a/libs/server-sdk-redis-source/src/CMakeLists.txt b/libs/server-sdk-redis-source/src/CMakeLists.txt index c7c545822..e459a1dd0 100644 --- a/libs/server-sdk-redis-source/src/CMakeLists.txt +++ b/libs/server-sdk-redis-source/src/CMakeLists.txt @@ -14,6 +14,7 @@ target_sources(${LIBNAME} PRIVATE ${HEADER_LIST} redis_source.cpp + redis_big_segment_store.cpp bindings/redis/redis_source.cpp ) diff --git a/libs/server-sdk-redis-source/src/redis_big_segment_store.cpp b/libs/server-sdk-redis-source/src/redis_big_segment_store.cpp new file mode 100644 index 000000000..bd3d02026 --- /dev/null +++ b/libs/server-sdk-redis-source/src/redis_big_segment_store.cpp @@ -0,0 +1,93 @@ +#include + +#include + +#include +#include +#include +#include +#include + +namespace launchdarkly::server_side::integrations { + +namespace { + +// Schema strings from the LaunchDarkly Big Segments spec; must match what +// Relay writes byte-for-byte. +// +// Membership keys are three-part: {user-prefix}:{namespace}:{context-hash}, +// where each membership state has its own namespace. Sync-time is two-part: +// {user-prefix}:big_segments_synchronized_on. +constexpr char kIncludeKeyNamespace[] = "big_segment_include"; +constexpr char kExcludeKeyNamespace[] = "big_segment_exclude"; +constexpr char kSyncTimeKey[] = "big_segments_synchronized_on"; + +} // namespace + +tl::expected, std::string> +RedisBigSegmentStore::Create(std::string uri, std::string prefix) { + try { + return std::unique_ptr(new RedisBigSegmentStore( + std::make_unique(std::move(uri)), + std::move(prefix))); + } catch (sw::redis::Error const& e) { + return tl::make_unexpected(e.what()); + } +} + +RedisBigSegmentStore::RedisBigSegmentStore( + std::unique_ptr redis, + std::string prefix) + : redis_(std::move(redis)), + prefix_(std::move(prefix)), + sync_time_key_(prefix_ + ":" + kSyncTimeKey) {} + +RedisBigSegmentStore::~RedisBigSegmentStore() = default; + +IBigSegmentStore::GetMembershipResult RedisBigSegmentStore::GetMembership( + std::string const& context_hash) const { + std::string const include_key = + prefix_ + ":" + kIncludeKeyNamespace + ":" + context_hash; + std::string const exclude_key = + prefix_ + ":" + kExcludeKeyNamespace + ":" + context_hash; + + std::vector included; + std::vector excluded; + + try { + redis_->smembers(include_key, std::back_inserter(included)); + redis_->smembers(exclude_key, std::back_inserter(excluded)); + } catch (sw::redis::Error const& e) { + return tl::make_unexpected(e.what()); + } + + if (included.empty() && excluded.empty()) { + return std::nullopt; + } + return Membership::FromSegmentRefs(included, excluded); +} + +IBigSegmentStore::GetMetadataResult RedisBigSegmentStore::GetMetadata() const { + sw::redis::OptionalString raw; + try { + raw = redis_->get(sync_time_key_); + } catch (sw::redis::Error const& e) { + return tl::make_unexpected(e.what()); + } + + if (!raw) { + return std::nullopt; + } + + errno = 0; + char* end = nullptr; + long long const parsed = std::strtoll(raw->c_str(), &end, 10); + if (errno != 0 || end == raw->c_str() || *end != '\0') { + return tl::make_unexpected( + "Redis Big Segments synchronized_on is not a valid integer"); + } + + return StoreMetadata{std::chrono::milliseconds{parsed}}; +} + +} // namespace launchdarkly::server_side::integrations diff --git a/libs/server-sdk-redis-source/tests/redis_big_segment_store_test.cpp b/libs/server-sdk-redis-source/tests/redis_big_segment_store_test.cpp new file mode 100644 index 000000000..18d6c0ae4 --- /dev/null +++ b/libs/server-sdk-redis-source/tests/redis_big_segment_store_test.cpp @@ -0,0 +1,155 @@ +#include + +#include + +#include + +#include +#include +#include + +using namespace launchdarkly::server_side::integrations; + +namespace { + +class RedisBigSegmentTests : public ::testing::Test { + public: + RedisBigSegmentTests() + : uri_("redis://localhost:6379"), + prefix_("testprefix"), + client_(uri_) {} + + void SetUp() override { + try { + client_.flushdb(); + } catch (sw::redis::Error const& e) { + FAIL() << "couldn't clear Redis: " << e.what(); + } + + auto maybe_store = RedisBigSegmentStore::Create(uri_, prefix_); + ASSERT_TRUE(maybe_store); + store_ = std::move(*maybe_store); + } + + void AddIncludes(std::string const& context_hash, + std::vector const& refs) { + AddIncludesUnderPrefix(prefix_, context_hash, refs); + } + + void AddIncludesUnderPrefix(std::string const& prefix, + std::string const& context_hash, + std::vector const& refs) { + auto const key = prefix + ":big_segment_include:" + context_hash; + for (auto const& ref : refs) { + client_.sadd(key, ref); + } + } + + void AddExcludes(std::string const& context_hash, + std::vector const& refs) { + auto const key = prefix_ + ":big_segment_exclude:" + context_hash; + for (auto const& ref : refs) { + client_.sadd(key, ref); + } + } + + void SetSyncTime(std::int64_t millis) { + SetSyncTimeRaw(std::to_string(millis)); + } + + void SetSyncTimeRaw(std::string const& value) { + client_.set(prefix_ + ":big_segments_synchronized_on", value); + } + + protected: + std::unique_ptr store_; + + private: + std::string const uri_; + std::string const prefix_; + sw::redis::Redis client_; +}; + +} // namespace + +TEST_F(RedisBigSegmentTests, EmptyStoreReturnsNoMembership) { + auto const result = store_->GetMembership("nobody"); + ASSERT_TRUE(result); + ASSERT_FALSE(result->has_value()); +} + +TEST_F(RedisBigSegmentTests, EmptyStoreReturnsNoMetadata) { + auto const result = store_->GetMetadata(); + ASSERT_TRUE(result); + ASSERT_FALSE(result->has_value()); +} + +TEST_F(RedisBigSegmentTests, GetMembershipWithIncludesOnly) { + AddIncludes("alice", {"seg1.g1", "seg2.g3"}); + + auto const result = store_->GetMembership("alice"); + ASSERT_TRUE(result); + ASSERT_TRUE(result->has_value()); + + auto const& m = result->value(); + ASSERT_EQ(m.CheckMembership("seg1.g1"), true); + ASSERT_EQ(m.CheckMembership("seg2.g3"), true); + ASSERT_FALSE(m.CheckMembership("seg3.g1").has_value()); +} + +TEST_F(RedisBigSegmentTests, GetMembershipWithExcludesOnly) { + AddExcludes("bob", {"seg1.g1"}); + + auto const result = store_->GetMembership("bob"); + ASSERT_TRUE(result); + ASSERT_TRUE(result->has_value()); + + auto const& m = result->value(); + ASSERT_EQ(m.CheckMembership("seg1.g1"), false); +} + +TEST_F(RedisBigSegmentTests, GetMembershipInclusionWinsOverExclusion) { + AddIncludes("carol", {"seg.g1"}); + AddExcludes("carol", {"seg.g1"}); + + auto const result = store_->GetMembership("carol"); + ASSERT_TRUE(result); + ASSERT_TRUE(result->has_value()); + ASSERT_EQ(result->value().CheckMembership("seg.g1"), true); +} + +TEST_F(RedisBigSegmentTests, GetMembershipIsPrefixScoped) { + // Write under a different prefix; same-named test store should not see it. + AddIncludesUnderPrefix("otherprefix", "alice", {"seg1.g1"}); + + auto const result = store_->GetMembership("alice"); + ASSERT_TRUE(result); + ASSERT_FALSE(result->has_value()); +} + +TEST_F(RedisBigSegmentTests, GetMetadataReturnsSyncTime) { + SetSyncTime(1700000000000LL); + + auto const result = store_->GetMetadata(); + ASSERT_TRUE(result); + ASSERT_TRUE(result->has_value()); + ASSERT_EQ(result->value().last_up_to_date, + std::chrono::milliseconds{1700000000000LL}); +} + +TEST_F(RedisBigSegmentTests, GetMetadataRejectsMalformedSyncTime) { + SetSyncTimeRaw("not-a-number"); + + auto const result = store_->GetMetadata(); + ASSERT_FALSE(result); +} + +TEST(RedisBigSegmentStoreErrors, GetReturnsErrorOnUnreachableServer) { + auto maybe_store = + RedisBigSegmentStore::Create("tcp://foobar:1000", "prefix"); + ASSERT_TRUE(maybe_store); + + auto const store = std::move(*maybe_store); + ASSERT_FALSE(store->GetMembership("anyone")); + ASSERT_FALSE(store->GetMetadata()); +} diff --git a/libs/server-sdk/include/launchdarkly/server_side/integrations/big_segments/big_segment_store_types.hpp b/libs/server-sdk/include/launchdarkly/server_side/integrations/big_segments/big_segment_store_types.hpp new file mode 100644 index 000000000..a3ea7abaa --- /dev/null +++ b/libs/server-sdk/include/launchdarkly/server_side/integrations/big_segments/big_segment_store_types.hpp @@ -0,0 +1,97 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace launchdarkly::server_side::integrations { + +/** + * @brief Membership describes which Big Segments a single context belongs to, + * as reported by an @ref IBigSegmentStore lookup. + * + * Implementations of @ref IBigSegmentStore should construct a Membership using + * @ref Membership::FromSegmentRefs and return it from @ref + * IBigSegmentStore::GetMembership. The returned Membership is an immutable + * snapshot — once constructed it does not observe any later changes to the + * underlying store. + * + * A "segment ref" is the string `.g`; the SDK + * constructs that string when evaluating a flag and passes it to @ref + * CheckMembership. + * + * Implemented inline because integration libraries link against the + * server-sdk shared library, which only exports the C API — out-of-line + * symbols defined here would not be visible to consumers. + */ +class Membership { + public: + /** + * @brief Constructs a Membership from the lists of segment refs the + * context is included in / excluded from. + * + * If the same segment ref appears in both lists, inclusion wins, matching + * the LaunchDarkly Big Segments spec. + * + * @param included_segment_refs Segment refs the context is explicitly + * included in. + * @param excluded_segment_refs Segment refs the context is explicitly + * excluded from. + */ + static Membership FromSegmentRefs( + std::vector const& included_segment_refs, + std::vector const& excluded_segment_refs) { + std::unordered_map entries; + // Excluded first so any overlap is overwritten by the included pass; + // inclusion wins per the spec. + for (auto const& ref : excluded_segment_refs) { + entries[ref] = false; + } + for (auto const& ref : included_segment_refs) { + entries[ref] = true; + } + return Membership(std::move(entries)); + } + + /** + * @brief Returns the membership state for a single segment ref. + * + * @param segment_ref The `.g` ref to look up. + * @return `true` if the context is included, `false` if excluded, and + * `std::nullopt` if the segment ref has no entry in this membership. + */ + [[nodiscard]] std::optional CheckMembership( + std::string const& segment_ref) const { + auto const it = entries_.find(segment_ref); + if (it == entries_.end()) { + return std::nullopt; + } + return it->second; + } + + private: + explicit Membership(std::unordered_map entries) + : entries_(std::move(entries)) {} + + // segment-ref → true (included) / false (excluded). Inclusion is the + // stored value when a ref appears in both lists at construction time. + std::unordered_map entries_; +}; + +/** + * @brief Metadata describing the Big Segments store as a whole. Used to + * detect staleness independent of any single context's membership. + */ +struct StoreMetadata { + /** + * @brief Wall-clock timestamp (Unix epoch) at which the data populator + * (e.g. the LaunchDarkly Relay Proxy) last confirmed it had pushed all + * pending Big Segments updates to the store. + */ + std::chrono::milliseconds last_up_to_date; +}; + +} // namespace launchdarkly::server_side::integrations diff --git a/libs/server-sdk/include/launchdarkly/server_side/integrations/big_segments/ibig_segment_store.hpp b/libs/server-sdk/include/launchdarkly/server_side/integrations/big_segments/ibig_segment_store.hpp new file mode 100644 index 000000000..6ee463f00 --- /dev/null +++ b/libs/server-sdk/include/launchdarkly/server_side/integrations/big_segments/ibig_segment_store.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include + +#include + +#include +#include + +namespace launchdarkly::server_side::integrations { + +/** + * @brief Interface for a Big Segments persistent store. + * + * A Big Segment is a segment whose membership list lives outside the + * LaunchDarkly flag payload, in an external shared store populated by the + * LaunchDarkly Relay Proxy. At evaluation time the SDK does a point lookup + * against the store rather than carrying the full membership list in memory. + * + * Implementations of this interface live in dedicated integration libraries + * (e.g. `server-sdk-redis-source`, `server-sdk-dynamodb-source`) and are + * passed to the SDK via the Big Segments config builder. The SDK wraps every + * implementation in an internal caching / staleness-tracking layer, so + * implementations should NOT cache results themselves — perform every lookup + * the SDK asks for. + * + * The SDK hashes the context key (SHA-256 then base64-encoded) before + * calling @ref GetMembership, so an implementation only ever sees opaque + * hashes; raw context keys are never sent to the store. + * + * Implementations must be thread-safe. + */ +class IBigSegmentStore { + public: + virtual ~IBigSegmentStore() = default; + IBigSegmentStore(IBigSegmentStore const&) = delete; + IBigSegmentStore(IBigSegmentStore&&) = delete; + IBigSegmentStore& operator=(IBigSegmentStore const&) = delete; + IBigSegmentStore& operator=(IBigSegmentStore&&) = delete; + + using GetMembershipResult = + tl::expected, std::string>; + using GetMetadataResult = + tl::expected, std::string>; + + /** + * @brief Looks up the Big Segments membership for a single context. + * + * @param context_hash Base64-encoded SHA-256 of the context key. + * Implementations should treat this as an opaque identifier. + * + * @return A @ref Membership snapshot if the store has any record for this + * context hash, `std::nullopt` if there is no record (which is a normal + * case — most contexts are in no Big Segments), or an error if the lookup + * itself failed. + */ + [[nodiscard]] virtual GetMembershipResult GetMembership( + std::string const& context_hash) const = 0; + + /** + * @brief Returns store-level metadata used by the SDK to detect staleness. + * + * @return @ref StoreMetadata if the store has a metadata record, + * `std::nullopt` if no metadata has ever been written (the store was + * never populated), or an error if the lookup itself failed. + */ + [[nodiscard]] virtual GetMetadataResult GetMetadata() const = 0; + + protected: + IBigSegmentStore() = default; +}; + +} // namespace launchdarkly::server_side::integrations diff --git a/libs/server-sdk/src/CMakeLists.txt b/libs/server-sdk/src/CMakeLists.txt index 49630fbb3..c6defb494 100644 --- a/libs/server-sdk/src/CMakeLists.txt +++ b/libs/server-sdk/src/CMakeLists.txt @@ -2,6 +2,7 @@ file(GLOB HEADER_LIST CONFIGURE_DEPENDS "${LaunchDarklyCPPServer_SOURCE_DIR}/include/launchdarkly/server_side/*.hpp" "${LaunchDarklyCPPServer_SOURCE_DIR}/include/launchdarkly/server_side/integrations/*.hpp" + "${LaunchDarklyCPPServer_SOURCE_DIR}/include/launchdarkly/server_side/integrations/big_segments/*.hpp" "${LaunchDarklyCPPServer_SOURCE_DIR}/include/launchdarkly/server_side/hooks/*.hpp" ) diff --git a/libs/server-sdk/tests/big_segment_store_types_test.cpp b/libs/server-sdk/tests/big_segment_store_types_test.cpp new file mode 100644 index 000000000..47c16225c --- /dev/null +++ b/libs/server-sdk/tests/big_segment_store_types_test.cpp @@ -0,0 +1,39 @@ +#include + +#include + +using launchdarkly::server_side::integrations::Membership; + +TEST(MembershipTests, EmptyHasNoEntries) { + auto const m = Membership::FromSegmentRefs({}, {}); + ASSERT_FALSE(m.CheckMembership("seg.g1").has_value()); +} + +TEST(MembershipTests, IncludedReturnsTrue) { + auto const m = Membership::FromSegmentRefs({"seg1.g1", "seg2.g1"}, {}); + ASSERT_EQ(m.CheckMembership("seg1.g1"), true); + ASSERT_EQ(m.CheckMembership("seg2.g1"), true); +} + +TEST(MembershipTests, ExcludedReturnsFalse) { + auto const m = Membership::FromSegmentRefs({}, {"seg1.g1", "seg2.g1"}); + ASSERT_EQ(m.CheckMembership("seg1.g1"), false); + ASSERT_EQ(m.CheckMembership("seg2.g1"), false); +} + +TEST(MembershipTests, UnknownRefReturnsNullopt) { + auto const m = Membership::FromSegmentRefs({"seg1.g1"}, {"seg2.g1"}); + ASSERT_FALSE(m.CheckMembership("seg3.g1").has_value()); +} + +TEST(MembershipTests, InclusionWinsOverExclusion) { + // Same ref in both lists should resolve to "included" per spec. + auto const m = Membership::FromSegmentRefs({"seg.g1"}, {"seg.g1"}); + ASSERT_EQ(m.CheckMembership("seg.g1"), true); +} + +TEST(MembershipTests, DifferentGenerationsAreDistinct) { + auto const m = Membership::FromSegmentRefs({"seg.g2"}, {"seg.g1"}); + ASSERT_EQ(m.CheckMembership("seg.g1"), false); + ASSERT_EQ(m.CheckMembership("seg.g2"), true); +}