diff --git a/libs/client-sdk/src/CMakeLists.txt b/libs/client-sdk/src/CMakeLists.txt index 8c7e1d15c..625775ca5 100644 --- a/libs/client-sdk/src/CMakeLists.txt +++ b/libs/client-sdk/src/CMakeLists.txt @@ -14,6 +14,15 @@ endif () target_sources(${LIBNAME} PRIVATE ${HEADER_LIST} data_sources/streaming_data_source.cpp + data_sources/fdv2/fdv2_changeset_translation.cpp + data_sources/fdv2/fdv2_source_result.cpp + data_sources/fdv2/fdv2_response_headers.cpp + data_sources/fdv2/fdv2_polling_impl.cpp + data_sources/fdv2/polling_initializer.cpp + data_sources/fdv2/polling_synchronizer.cpp + data_sources/fdv2/streaming_synchronizer.cpp + data_sources/fdv2/fdv2_data_source.cpp + data_sources/fdv2/cache_initializer.cpp data_sources/data_source_event_handler.cpp data_sources/polling_data_source.cpp flag_manager/flag_store.cpp @@ -28,6 +37,20 @@ target_sources(${LIBNAME} PRIVATE data_sources/data_source_update_sink.hpp data_sources/polling_data_source.hpp data_sources/streaming_data_source.hpp + data_sources/fdv2/fdv2_changeset_translation.hpp + data_sources/fdv2/fdv2_source_result.hpp + data_sources/fdv2/ifdv2_initializer.hpp + data_sources/fdv2/ifdv2_initializer_factory.hpp + data_sources/fdv2/ifdv2_synchronizer.hpp + data_sources/fdv2/ifdv2_synchronizer_factory.hpp + data_sources/fdv2/fdv2_request_config.hpp + data_sources/fdv2/fdv2_response_headers.hpp + data_sources/fdv2/fdv2_polling_impl.hpp + data_sources/fdv2/polling_initializer.hpp + data_sources/fdv2/polling_synchronizer.hpp + data_sources/fdv2/streaming_synchronizer.hpp + data_sources/fdv2/fdv2_data_source.hpp + data_sources/fdv2/cache_initializer.hpp flag_manager/flag_store.hpp flag_manager/flag_updater.hpp bindings/c/sdk.cpp diff --git a/libs/client-sdk/src/data_sources/data_source_update_sink.hpp b/libs/client-sdk/src/data_sources/data_source_update_sink.hpp index 1149618c9..73cbdb236 100644 --- a/libs/client-sdk/src/data_sources/data_source_update_sink.hpp +++ b/libs/client-sdk/src/data_sources/data_source_update_sink.hpp @@ -4,19 +4,36 @@ #include #include #include +#include #include #include #include #include +#include #include namespace launchdarkly::client_side { using ItemDescriptor = data_model::ItemDescriptor; +// One flag's new state: an evaluation result, or an empty descriptor +// (tombstone) if the flag was deleted. +struct FlagChange { + std::string key; + ItemDescriptor item; +}; + +using FlagChangeSetData = std::vector; + +using FlagChangeSet = data_model::ChangeSet; + /** * Interface for handling updates from LaunchDarkly. + * + * Implementations must be thread-safe. A data source calls these from + * whichever thread it runs on, concurrently with flag evaluation and listener + * registration on the application's threads. */ class IDataSourceUpdateSink { public: @@ -26,6 +43,20 @@ class IDataSourceUpdateSink { std::string key, ItemDescriptor item) = 0; + /** + * Applies a changeset as a single unit. Unlike Upsert, this path does not + * order or reject updates by version. FDv2 reserves the per-flag version + * for event tracking and manages payload consistency through selectors + * instead. + * + * @param from_cache Whether the changeset was loaded from the local + * cache. Cached data is not written back to the cache, and does not + * count as the service confirming the cache is current. + */ + virtual void Apply(Context const& context, + FlagChangeSet change_set, + bool from_cache) = 0; + IDataSourceUpdateSink(IDataSourceUpdateSink const& item) = delete; IDataSourceUpdateSink(IDataSourceUpdateSink&& item) = delete; IDataSourceUpdateSink& operator=(IDataSourceUpdateSink const&) = delete; diff --git a/libs/client-sdk/src/data_sources/fdv2/cache_initializer.cpp b/libs/client-sdk/src/data_sources/fdv2/cache_initializer.cpp new file mode 100644 index 000000000..59fd87900 --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/cache_initializer.cpp @@ -0,0 +1,61 @@ +#include "cache_initializer.hpp" + +#include + +namespace launchdarkly::client_side::data_sources { + +static char const* const kIdentity = "FDv2 cache initializer"; + +FDv2CacheInitializer::FDv2CacheInitializer(flag_manager::FlagPersistence* cache, + Context context, + Logger const& logger) + : cache_(cache), context_(std::move(context)), logger_(logger) {} + +async::Future FDv2CacheInitializer::Run() { + auto data = cache_->ReadCached(context_); + if (!data) { + LD_LOG(logger_, LogLevel::kDebug) + << kIdentity << ": no cached data for this context"; + // A miss leaves the data set unchanged and lets initialization + // continue, which is what a "none" intent means. + return async::MakeFuture(FDv2SourceResult{FDv2SourceResult::ChangeSet{ + FlagChangeSet{data_model::ChangeSetType::kNone, + {}, + data_model::Selector{}}}}); + } + + LD_LOG(logger_, LogLevel::kDebug) + << kIdentity << ": loaded " << data->size() + << " flags for this context"; + + FlagChangeSetData changes; + changes.reserve(data->size()); + for (auto& [key, item] : *data) { + changes.push_back(FlagChange{key, std::move(item)}); + } + + return async::MakeFuture(FDv2SourceResult{FDv2SourceResult::ChangeSet{ + FlagChangeSet{data_model::ChangeSetType::kFull, std::move(changes), + data_model::Selector{}}}}); +} + +void FDv2CacheInitializer::Close() { + // Run() completes on the calling thread, so there is nothing to cancel. +} + +std::string const& FDv2CacheInitializer::Identity() const { + static std::string const identity = kIdentity; + return identity; +} + +FDv2CacheInitializerFactory::FDv2CacheInitializerFactory( + flag_manager::FlagPersistence* cache, + Context context, + Logger const& logger) + : cache_(cache), context_(std::move(context)), logger_(logger) {} + +std::unique_ptr FDv2CacheInitializerFactory::Build() { + return std::make_unique(cache_, context_, logger_); +} + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/fdv2/cache_initializer.hpp b/libs/client-sdk/src/data_sources/fdv2/cache_initializer.hpp new file mode 100644 index 000000000..4adf976d7 --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/cache_initializer.hpp @@ -0,0 +1,80 @@ +#pragma once + +#include "ifdv2_initializer.hpp" +#include "ifdv2_initializer_factory.hpp" + +#include "../../flag_manager/flag_persistence.hpp" + +#include +#include +#include + +#include + +namespace launchdarkly::client_side::data_sources { + +/** + * Loads flag data the SDK persisted for this context on a previous run, so + * that evaluation can begin before the network answers. + * + * The cache is read on the calling thread. The read is fast enough that + * dispatching it to the executor would cost more than it saves. + * + * Cached data never carries a selector. A selector names a state the service + * can compute changes against, and the SDK does not verify that persisted + * data is intact. Asking for a delta against data that may not be what the + * service thinks it is would corrupt the store silently. Initialization + * therefore continues past the cache to a network source, which supplies both + * data and a selector. + * + * Run() reads the cache on the calling thread and returns a future that is + * already resolved. Close() may be called from any thread and has nothing to + * cancel. + */ +class FDv2CacheInitializer final : public IFDv2Initializer { + public: + /** + * @param cache The local cache to read. Non-owning. Must outlive this + * object. + * @param context The evaluation context to load data for. + * @param logger Receives diagnostic logging. + */ + FDv2CacheInitializer(flag_manager::FlagPersistence* cache, + Context context, + Logger const& logger); + + async::Future Run() override; + + void Close() override; + + [[nodiscard]] std::string const& Identity() const override; + + private: + flag_manager::FlagPersistence* const cache_; + Context const context_; + Logger logger_; +}; + +/** + * Builds fresh FDv2CacheInitializer instances on demand. + * + * Thread-safe: Build() may be called from any thread. The cache pointer and + * context it hands to each initializer are fixed at construction. + */ +class FDv2CacheInitializerFactory final : public IFDv2InitializerFactory { + public: + FDv2CacheInitializerFactory(flag_manager::FlagPersistence* cache, + Context context, + Logger const& logger); + + std::unique_ptr Build() override; + + [[nodiscard]] bool IsFromCache() const override { return true; } + + private: + flag_manager::FlagPersistence* const cache_; + Context const context_; + Logger logger_; +}; + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/fdv2/fdv2_changeset_translation.cpp b/libs/client-sdk/src/data_sources/fdv2/fdv2_changeset_translation.cpp new file mode 100644 index 000000000..b47e7ccb7 --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/fdv2_changeset_translation.cpp @@ -0,0 +1,70 @@ +#include "fdv2_changeset_translation.hpp" + +#include + +#include + +namespace launchdarkly::client_side::data_sources { + +using data_model::ChangeSetType; +using data_model::FDv2Change; +using data_model::FDv2ChangeSet; + +// FDv2 "kind" tag for a client-side evaluated flag. +static char const* const kFlagEval = "flag-eval"; + +std::optional TranslateChangeSet(FDv2ChangeSet const& change_set, + Logger const& logger) { + if (change_set.type == ChangeSetType::kNone) { + return FlagChangeSet{change_set.type, {}, change_set.selector}; + } + + FlagChangeSetData changes; + changes.reserve(change_set.changes.size()); + + for (auto const& change : change_set.changes) { + if (change.change_type == FDv2Change::ChangeType::kDelete) { + if (change.kind != kFlagEval) { + LD_LOG(logger, LogLevel::kWarn) + << "FDv2: unknown kind '" << change.kind + << "' in delete-object, skipping"; + continue; + } + changes.push_back(FlagChange{ + change.key, + ItemDescriptor{data_model::Tombstone{change.version}}}); + } else if (change.change_type == FDv2Change::ChangeType::kPut) { + if (change.kind != kFlagEval) { + LD_LOG(logger, LogLevel::kWarn) + << "FDv2: unknown kind '" << change.kind + << "' in put-object, skipping"; + continue; + } + + auto result = ParseEvaluationResult(change.object, change.version); + if (!result) { + LD_LOG(logger, LogLevel::kError) + << "FDv2: could not deserialize flag '" << change.key + << "'"; + return std::nullopt; + } + if (!result->has_value()) { + LD_LOG(logger, LogLevel::kWarn) + << "FDv2: flag '" << change.key + << "' object was null, skipping"; + continue; + } + changes.push_back( + FlagChange{change.key, ItemDescriptor{std::move(**result)}}); + } else { + LD_LOG(logger, LogLevel::kWarn) + << "FDv2: unrecognized change type " + << static_cast(change.change_type) << ", skipping"; + } + } + + return FlagChangeSet{change_set.type, std::move(changes), + change_set.selector}; +} + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/fdv2/fdv2_changeset_translation.hpp b/libs/client-sdk/src/data_sources/fdv2/fdv2_changeset_translation.hpp new file mode 100644 index 000000000..a8d6bc7b2 --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/fdv2_changeset_translation.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "../data_source_update_sink.hpp" + +#include +#include + +#include + +namespace launchdarkly::client_side::data_sources { + +/** + * Translates an FDv2ChangeSet into flag changes ready to apply to the store. + * + * Unknown kinds are logged and skipped, for forward compatibility. Returns + * nullopt if a flag-eval object fails to deserialize. + */ +std::optional TranslateChangeSet( + data_model::FDv2ChangeSet const& change_set, + Logger const& logger); + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/fdv2/fdv2_data_source.cpp b/libs/client-sdk/src/data_sources/fdv2/fdv2_data_source.cpp new file mode 100644 index 000000000..539ac0743 --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/fdv2_data_source.cpp @@ -0,0 +1,538 @@ +#include "fdv2_data_source.hpp" + +#include + +#include + +#include +#include +#include +#include + +namespace launchdarkly::client_side::data_sources { + +namespace { + +// Lets std::visit dispatch to a different lambda per variant alternative. +template +struct overloaded : Ts... { + using Ts::operator()...; +}; +template +overloaded(Ts...) -> overloaded; + +// Reduces a source result to the signal the conditions act on. +SourceSignal ClassifyResult(FDv2SourceResult const& result) { + if (std::get_if(&result.value)) { + return SourceSignal::kChangeSet; + } + if (std::get_if(&result.value)) { + return SourceSignal::kInterrupted; + } + return SourceSignal::kOther; +} + +bool AllFromCache( + std::vector> const& factories) { + return std::all_of( + factories.begin(), factories.end(), + [](auto const& factory) { return factory->IsFromCache(); }); +} + +} // namespace + +FDv2DataSource::FDv2DataSource( + std::vector> initializer_factories, + std::vector> + synchronizer_factories, + std::unique_ptr fallback_condition_factory, + std::unique_ptr recovery_condition_factory, + boost::asio::any_io_executor executor, + Context context, + IDataSourceUpdateSink* sink, + flag_manager::FlagStore const* store, + DataSourceStatusManager* status_manager, + Logger const& logger) + : logger_(logger), + executor_(std::move(executor)), + initializer_factories_(std::move(initializer_factories)), + fallback_condition_factory_(std::move(fallback_condition_factory)), + recovery_condition_factory_(std::move(recovery_condition_factory)), + context_(std::move(context)), + cache_only_(!initializer_factories_.empty() && + synchronizer_factories.empty() && + AllFromCache(initializer_factories_)), + sink_(sink), + store_(store), + status_manager_(status_manager), + start_called_(false), + last_logged_synchronizer_interrupted_(false), + closed_(false), + received_data_(false), + initializer_index_(0), + active_initializer_from_cache_(false), + source_manager_(std::move(synchronizer_factories)), + active_initializer_(nullptr), + active_synchronizer_(nullptr), + active_conditions_(nullptr) {} + +FDv2DataSource::~FDv2DataSource() { + Close(); +} + +void FDv2DataSource::Close() { + std::lock_guard lock(mutex_); + closed_ = true; + if (active_initializer_) { + active_initializer_->Close(); + } + if (active_synchronizer_) { + active_synchronizer_->Close(); + } + if (active_conditions_) { + active_conditions_->Close(); + } +} + +std::optional FDv2DataSource::EnvironmentId() const { + std::lock_guard lock(mutex_); + return environment_id_; +} + +void FDv2DataSource::PublishState(DataSourceStatus::DataSourceState state) { + auto promise = GuardShutdown(); + if (!promise) { + return; + } + status_manager_->SetState(state); + promise->Resolve({}); +} + +void FDv2DataSource::PublishState(DataSourceStatus::DataSourceState state, + DataSourceStatus::ErrorInfo::ErrorKind kind, + std::string message) { + auto promise = GuardShutdown(); + if (!promise) { + return; + } + status_manager_->SetState(state, kind, std::move(message)); + promise->Resolve({}); +} + +void FDv2DataSource::Start() { + bool const already_called = start_called_.exchange(true); + assert(!already_called && "Start() must be called at most once"); + + // Don't let ShutdownAsync call its callback while Start() is in progress. + auto promise = GuardShutdown(); + if (!promise) { + return; + } + + PublishState(DataSourceStatus::DataSourceState::kInitializing); + + LD_LOG(logger_, LogLevel::kInfo) << "fdv2: starting"; + if (initializer_factories_.empty() && + source_manager_.SynchronizerCount() == 0) { + // Nothing is configured to supply data, so an empty store is the + // canonical state. + PublishState(DataSourceStatus::DataSourceState::kValid); + } else { + // Evaluation can use cached flags as soon as Start() returns, the way + // it could when the client loaded the cache in its constructor. + RunCacheInitializers(); + + boost::asio::post(executor_, [weak = weak_from_this()]() { + if (auto self = weak.lock()) { + self->RunNextInitializer(); + } + }); + } + + promise->Resolve({}); +} + +void FDv2DataSource::RunCacheInitializers() { + while (true) { + std::unique_ptr initializer; + { + std::lock_guard lock(mutex_); + if (closed_ || + initializer_index_ >= initializer_factories_.size() || + !initializer_factories_[initializer_index_]->IsFromCache()) { + return; + } + initializer = initializer_factories_[initializer_index_]->Build(); + } + + auto future = initializer->Run(); + if (!future.IsFinished()) { + // Nothing here can wait on it, so the chain runs this initializer + // instead. The index is left where it is. + initializer->Close(); + return; + } + + { + std::lock_guard lock(mutex_); + ++initializer_index_; + } + + auto result = future.GetResult(); + if (result) { + if (auto* change_set = + std::get_if(&result->value)) { + LD_LOG(logger_, LogLevel::kInfo) + << "fdv2: applying cached data from " + << initializer->Identity(); + ApplyResult(std::move(*change_set), + std::move(result->environment_id), + /* from_cache= */ true); + } + } + initializer->Close(); + } +} + +void FDv2DataSource::ShutdownAsync(std::function completion) { + // Report initializing so that a caller waiting on the next status change, + // such as identify, sees the restart. This runs before Close(), which + // stops any further transitions from this source. + PublishState(DataSourceStatus::DataSourceState::kInitializing); + Close(); + + if (completion) { + std::lock_guard lock(mutex_); + closing_.Then( + [completion](std::monostate _) { + completion(); + return std::monostate{}; + }, + [executor = executor_](async::Continuation work) { + boost::asio::post(executor, std::move(work)); + }); + } +} + +void FDv2DataSource::RunNextInitializer() { + bool exhausted = false; + { + std::lock_guard lock(mutex_); + if (closed_) { + return; + } + if (initializer_index_ >= initializer_factories_.size()) { + exhausted = true; + } else { + auto& factory = initializer_factories_[initializer_index_++]; + active_initializer_from_cache_ = factory->IsFromCache(); + active_initializer_ = factory->Build(); + LD_LOG(logger_, LogLevel::kInfo) << "fdv2: starting initializer " + << active_initializer_->Identity(); + active_initializer_->Run().Then( + [weak = weak_from_this()]( + FDv2SourceResult const& result) -> std::monostate { + if (auto self = weak.lock()) { + self->OnInitializerResult(result); + } + return {}; + }, + [executor = executor_](async::Continuation work) { + boost::asio::post(executor, std::move(work)); + }); + } + } + + if (exhausted) { + StartSynchronizers(); + } +} + +void FDv2DataSource::OnInitializerResult(FDv2SourceResult result) { + bool got_basis = false; + bool got_shutdown = false; + bool from_cache = false; + { + std::lock_guard lock(mutex_); + from_cache = active_initializer_from_cache_; + } + + std::visit( + overloaded{ + [&](FDv2SourceResult::ChangeSet& cs) { + bool const has_selector = + cs.change_set.selector.value.has_value(); + ApplyResult(std::move(cs), std::move(result.environment_id), + from_cache); + if (has_selector) { + LD_LOG(logger_, LogLevel::kInfo) + << "fdv2: initializer succeeded"; + got_basis = true; + } + }, + [&](FDv2SourceResult::Shutdown&) { got_shutdown = true; }, + [&](FDv2SourceResult::Interrupted const& iv) { + LD_LOG(logger_, LogLevel::kWarn) + << "fdv2: initializer interrupted: " << iv.error.Message(); + PublishState(DataSourceStatus::DataSourceState::kInterrupted, + iv.error.Kind(), iv.error.Message()); + }, + [&](FDv2SourceResult::TerminalError const& te) { + LD_LOG(logger_, LogLevel::kWarn) + << "fdv2: initializer terminal error: " + << te.error.Message(); + PublishState(DataSourceStatus::DataSourceState::kInterrupted, + te.error.Kind(), te.error.Message()); + }, + [&](FDv2SourceResult::Goodbye const&) { + LD_LOG(logger_, LogLevel::kDebug) + << "fdv2: ignoring goodbye from initializer"; + }, + }, + result.value); + + { + std::lock_guard lock(mutex_); + active_initializer_.reset(); + if (closed_ || got_shutdown) { + return; + } + } + + if (got_basis) { + StartSynchronizers(); + } else { + RunNextInitializer(); + } +} + +void FDv2DataSource::StartSynchronizers() { + bool exhausted = false; + bool any_synchronizers_configured = false; + { + std::lock_guard lock(mutex_); + if (closed_) { + return; + } + active_synchronizer_ = source_manager_.NextSynchronizer(); + if (active_synchronizer_) { + LD_LOG(logger_, LogLevel::kInfo) + << "fdv2: starting synchronizer " + << active_synchronizer_->Identity(); + last_logged_synchronizer_interrupted_.store(false); + active_conditions_ = BuildActiveConditions(); + } else { + exhausted = true; + any_synchronizers_configured = + source_manager_.SynchronizerCount() > 0; + } + } + + if (exhausted) { + ReportExhausted(any_synchronizers_configured); + return; + } + + RunSynchronizerNext(); +} + +void FDv2DataSource::ReportExhausted(bool any_synchronizers_configured) { + if (cache_only_) { + // The cache is the only thing that could ever have supplied data, so + // a miss is not a failure to initialize. It just means there are no + // flags. + PublishState(DataSourceStatus::DataSourceState::kValid); + return; + } + + bool received_data = false; + { + std::lock_guard lock(mutex_); + received_data = received_data_; + } + if (!any_synchronizers_configured && received_data) { + // The initializers supplied data and nothing is configured to keep it + // current, which is a complete, successful run. + return; + } + + std::string const message = + any_synchronizers_configured + ? "all data source acquisition methods have been exhausted" + : "all initializers exhausted and no synchronizers configured"; + LD_LOG(logger_, LogLevel::kWarn) << "fdv2: " << message; + PublishState(DataSourceStatus::DataSourceState::kShutdown, + DataSourceStatus::ErrorInfo::ErrorKind::kUnknown, message); +} + +void FDv2DataSource::RunSynchronizerNext() { + std::lock_guard lock(mutex_); + if (closed_ || !active_synchronizer_) { + return; + } + auto next_future = active_synchronizer_->Next(store_->CurrentSelector()); + auto cond_cancel = std::make_shared(); + auto cond_future = active_conditions_->GetFuture(cond_cancel->GetToken()); + async::WhenAny(cond_future, next_future) + .Then( + [weak = weak_from_this(), cond_future, next_future, + cond_cancel](std::size_t const& idx) -> std::monostate { + cond_cancel->Cancel(); + auto self = weak.lock(); + if (!self) { + return {}; + } + if (idx == 0) { + self->OnConditionFired(*cond_future.GetResult()); + } else { + self->OnSynchronizerResult(*next_future.GetResult()); + } + return {}; + }, + [executor = executor_](async::Continuation work) { + boost::asio::post(executor, std::move(work)); + }); +} + +void FDv2DataSource::OnConditionFired(IFDv2Condition::Type type) { + if (type == IFDv2Condition::Type::kCancelled) { + return; + } + { + std::lock_guard lock(mutex_); + if (closed_) { + return; + } + // Destructors close the active synchronizer and conditions. + active_synchronizer_.reset(); + active_conditions_.reset(); + if (type == IFDv2Condition::Type::kRecovery) { + LD_LOG(logger_, LogLevel::kInfo) << "fdv2: recovery condition met"; + source_manager_.ResetSourceIndex(); + } else { + LD_LOG(logger_, LogLevel::kInfo) << "fdv2: fallback condition met"; + } + } + StartSynchronizers(); +} + +std::unique_ptr FDv2DataSource::BuildActiveConditions() const { + std::vector> conditions; + // With only one synchronizer available there's nothing to fall back to + // or recover from, so leave the conditions empty. + if (source_manager_.AvailableSynchronizerCount() == 1) { + return std::make_unique(std::move(conditions)); + } + if (fallback_condition_factory_) { + conditions.push_back(fallback_condition_factory_->Build()); + } + // The prime synchronizer has nothing more-preferred to recover to. + if (!source_manager_.IsPrimeSynchronizer() && recovery_condition_factory_) { + conditions.push_back(recovery_condition_factory_->Build()); + } + return std::make_unique(std::move(conditions)); +} + +void FDv2DataSource::OnSynchronizerResult(FDv2SourceResult result) { + { + std::lock_guard lock(mutex_); + if (closed_) { + return; + } + if (active_conditions_) { + active_conditions_->Inform(ClassifyResult(result)); + } + } + + bool got_shutdown = false; + bool advance = false; + + std::visit( + overloaded{ + [&](FDv2SourceResult::ChangeSet& cs) { + last_logged_synchronizer_interrupted_.store(false); + ApplyResult(std::move(cs), std::move(result.environment_id), + /* from_cache= */ false); + }, + [&](FDv2SourceResult::Shutdown&) { got_shutdown = true; }, + [&](FDv2SourceResult::Interrupted const& iv) { + if (!last_logged_synchronizer_interrupted_.exchange(true)) { + LD_LOG(logger_, LogLevel::kInfo) + << "fdv2: synchronizer interrupted: " + << iv.error.Message(); + } + PublishState(DataSourceStatus::DataSourceState::kInterrupted, + iv.error.Kind(), iv.error.Message()); + }, + [&](FDv2SourceResult::TerminalError const& te) { + LD_LOG(logger_, LogLevel::kWarn) + << "fdv2: synchronizer terminal error: " + << te.error.Message(); + PublishState(DataSourceStatus::DataSourceState::kInterrupted, + te.error.Kind(), te.error.Message()); + advance = true; + }, + [&](FDv2SourceResult::Goodbye const&) { + // The synchronizer restarts its own connection. + }, + }, + result.value); + + { + std::lock_guard lock(mutex_); + if (closed_ || got_shutdown) { + active_synchronizer_.reset(); + active_conditions_.reset(); + return; + } + if (advance) { + source_manager_.BlockCurrentSynchronizer(); + active_synchronizer_.reset(); + active_conditions_.reset(); + } + } + + if (advance) { + StartSynchronizers(); + } else { + RunSynchronizerNext(); + } +} + +void FDv2DataSource::ApplyResult(FDv2SourceResult::ChangeSet change_set, + std::optional environment_id, + bool from_cache) { + bool const carries_data = + change_set.change_set.type != data_model::ChangeSetType::kNone; + auto promise = GuardShutdown(); + if (!promise) { + return; + } + { + std::lock_guard lock(mutex_); + if (environment_id) { + environment_id_ = std::move(environment_id); + } + received_data_ = received_data_ || carries_data; + } + sink_->Apply(context_, std::move(change_set.change_set), from_cache); + PublishState(DataSourceStatus::DataSourceState::kValid); + promise->Resolve({}); +} + +std::optional> FDv2DataSource::GuardShutdown() { + std::lock_guard lock(mutex_); + if (closed_) { + return std::nullopt; + } + async::Promise promise; + auto future = promise.GetFuture(); + closing_ = + closing_.Then([future](std::monostate _) { return future; }, + [executor = executor_](async::Continuation work) { + boost::asio::post(executor, std::move(work)); + }); + return promise; +} + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/fdv2/fdv2_data_source.hpp b/libs/client-sdk/src/data_sources/fdv2/fdv2_data_source.hpp new file mode 100644 index 000000000..f5baf8280 --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/fdv2_data_source.hpp @@ -0,0 +1,238 @@ +#pragma once + +#include "../data_source.hpp" +#include "../data_source_status_manager.hpp" +#include "../data_source_update_sink.hpp" +#include "ifdv2_initializer_factory.hpp" +#include "ifdv2_synchronizer_factory.hpp" + +#include "../../flag_manager/flag_store.hpp" + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace launchdarkly::client_side::data_sources { + +// The orchestration primitives the client and server SDKs share. +using internal::data_sources::Conditions; +using internal::data_sources::FallbackConditionFactory; +using internal::data_sources::IFDv2Condition; +using internal::data_sources::IFDv2ConditionFactory; +using internal::data_sources::RecoveryConditionFactory; +using internal::data_sources::SourceSignal; + +using SourceManager = + internal::data_sources::SourceManager; + +/** + * The FDv2 data source. It runs a sequence of initializers to load flag data + * for one evaluation context, then hands off to a synchronizer to keep that + * data current, rotating synchronizers as they fail and recover. + * + * The data source is built for a single evaluation context. Changing context + * means shutting this one down and starting another. + * + * Lifecycle: + * 1. Construct. + * 2. Call Start() exactly once. It returns immediately, and orchestration + * runs on the executor. + * 3. Call ShutdownAsync() to stop. The completion runs on the executor once + * the source has stopped touching the store. + * + * Thread safety: Start, ShutdownAsync, and EnvironmentId may be called from + * any thread. + * + * Orchestration: + * + * Start() + * | + * v + * +-------------------+ no sources configured + * | Anything to do? |---------> [Done, status = kValid] + * +-------------------+ + * | + * v + * +-------------------+ initializer #N returns: + * | Initializer phase| ChangeSet(no selector) -> stay, N += 1 + * | N = 0, 1, 2, ... | ChangeSet(selector) -> go to Sync + * | | Interrupted/Terminal -> stay, N += 1 + * | | Goodbye -> stay, N += 1 + * | | Shutdown -> [Closed] + * +-------------------+ + * | + * | (N exhausted, or basis received) + * v + * +-------------------+ active synchronizer's Next returns: + * | Synchronizer | ChangeSet -> apply, loop + * | phase | Interrupted -> loop (source self-retries) + * | (cyclic; | Goodbye -> loop (source self-restarts) + * | blocked sources | TerminalError -> block, advance + * | are skipped) | Shutdown -> [Closed] + * +-------------------+ + * ^ | fallback condition -> advance (with wrap) + * | | recovery condition -> reset to first available + * +---+ + * | + * | (all synchronizers blocked) + * v + * [Done; final status preserved] + */ +class FDv2DataSource final + : public IDataSource, + public std::enable_shared_from_this { + public: + /** + * @param initializer_factories Build the initializers, run in order to + * load a basis. + * @param synchronizer_factories Build the synchronizers, used in order to + * keep data current once initialization is done. + * @param fallback_condition_factory Builds the per-synchronizer fallback + * condition. May be null, in which case synchronizers rotate only on + * terminal errors. + * @param recovery_condition_factory Builds the per-synchronizer recovery + * condition. May be null, in which case the source never returns to a + * more-preferred synchronizer once it has fallen back. + * @param executor Runs the orchestration. + * @param context The evaluation context this source loads data for. + * @param sink Receives the changesets. Non-owning. Must outlive this + * object. + * @param store Supplies the selector to request incremental updates + * against. Non-owning. Must outlive this object. + * @param status_manager Publishes data source status transitions. + * Non-owning. Must outlive this object. + * @param logger Receives diagnostic logging. + */ + FDv2DataSource( + std::vector> + initializer_factories, + std::vector> + synchronizer_factories, + std::unique_ptr fallback_condition_factory, + std::unique_ptr recovery_condition_factory, + boost::asio::any_io_executor executor, + Context context, + IDataSourceUpdateSink* sink, + flag_manager::FlagStore const* store, + DataSourceStatusManager* status_manager, + Logger const& logger); + + ~FDv2DataSource() override; + + void Start() override; + + void ShutdownAsync(std::function completion) override; + + /** + * The environment the service reported the most recent payload was + * evaluated in, or nullopt if no response has reported one. + */ + [[nodiscard]] std::optional EnvironmentId() const; + + private: + /** + * Signals the orchestration to stop and closes any active source. + * Idempotent. + */ + void Close(); + + // Orchestration steps. Each chains the next through Future::Then, so at + // most one step has a pending continuation at any time. mutex_ provides + // mutual exclusion for orchestration state, and lets Close() tear down + // active sources from any thread. + + // Publishes a status transition unless Close() has run. The client drops + // a data source as soon as its replacement starts, and a dropped source + // must not report over the new one. + void PublishState(DataSourceStatus::DataSourceState state); + void PublishState(DataSourceStatus::DataSourceState state, + DataSourceStatus::ErrorInfo::ErrorKind kind, + std::string message); + + // Applies the leading cache initializers on the calling thread, so that + // cached flags are available as soon as Start() returns. A cache + // initializer that does not complete synchronously is left to the chain. + void RunCacheInitializers(); + + void RunNextInitializer(); + void OnInitializerResult(FDv2SourceResult result); + void StartSynchronizers(); + void RunSynchronizerNext(); + void OnSynchronizerResult(FDv2SourceResult result); + void OnConditionFired(IFDv2Condition::Type type); + + // Checks for closed_ and queues up a promise for any work being done. + // Returns nullopt if closed_ already. + // Otherwise, returns a Promise that should be resolved when the current + // work is done and it's safe to signal ShutdownAsync complete. + std::optional> GuardShutdown(); + + // Builds the conditions to apply to the currently active synchronizer. + // Must be called with mutex_ held. Reads source_manager_. + std::unique_ptr BuildActiveConditions() const; + + // Applies a changeset to the store and records what the result reported + // about the environment. + void ApplyResult(FDv2SourceResult::ChangeSet change_set, + std::optional environment_id, + bool from_cache); + + // Reports that no source can supply data, choosing the status that + // reflects why. + void ReportExhausted(bool any_synchronizers_configured); + + // Logger is itself thread-safe and cheap to copy. + Logger logger_; + + // Immutable after construction. + boost::asio::any_io_executor const executor_; + std::vector> const + initializer_factories_; + std::unique_ptr const fallback_condition_factory_; + std::unique_ptr const recovery_condition_factory_; + Context const context_; + // True when the cache is the only source that could ever supply data, in + // which case a cache miss still completes initialization successfully. + bool const cache_only_; + + // Non-owning. Lifetimes guaranteed by the caller (see constructor doc). + IDataSourceUpdateSink* const sink_; + flag_manager::FlagStore const* const store_; + DataSourceStatusManager* const status_manager_; + + // Set by Start() to detect repeat or concurrent calls. + std::atomic_bool start_called_; + + // Suppresses consecutive "interrupted" logs from the active synchronizer. + std::atomic_bool last_logged_synchronizer_interrupted_; + + // Orchestration state, protected by mutex_. + mutable std::mutex mutex_; + bool closed_; + bool received_data_; + std::optional environment_id_; + std::size_t initializer_index_; + // Whether active_initializer_ reads from the local cache. + bool active_initializer_from_cache_; + SourceManager source_manager_; + std::unique_ptr active_initializer_; + std::unique_ptr active_synchronizer_; + std::unique_ptr active_conditions_; + // Any outstanding work to complete before signaling shutdown complete. + async::Future closing_ = + async::MakeFuture({}); +}; + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/fdv2/fdv2_polling_impl.cpp b/libs/client-sdk/src/data_sources/fdv2/fdv2_polling_impl.cpp new file mode 100644 index 000000000..a7e7dbab4 --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/fdv2_polling_impl.cpp @@ -0,0 +1,242 @@ +#include "fdv2_polling_impl.hpp" +#include "fdv2_changeset_translation.hpp" +#include "fdv2_response_headers.hpp" + +#include +#include +#include +#include + +#include +#include +#include + +#include + +namespace launchdarkly::client_side::data_sources { + +static char const* const kErrorParsingBody = + "Could not parse FDv2 polling response"; +static char const* const kErrorMissingEvents = + "FDv2 polling response missing 'events' array"; +static char const* const kErrorIncompletePayload = + "FDv2 polling response did not contain a complete payload"; +static char const* const kErrorTranslation = + "FDv2 polling response could not be translated"; + +using ErrorInfo = FDv2SourceResult::ErrorInfo; +using ErrorKind = ErrorInfo::ErrorKind; + +static ErrorInfo MakeError(ErrorKind kind, + ErrorInfo::StatusCodeType status, + std::string message) { + return ErrorInfo{kind, status, std::move(message), + std::chrono::system_clock::now()}; +} + +network::HttpRequest MakeFDv2PollRequest(FDv2RequestConfig const& config, + data_model::Selector const& selector) { + config::shared::builders::HttpPropertiesBuilder + builder(config.http_properties); + + bool const post = config.transport == FDv2ContextTransport::kPostBody; + if (post) { + builder.Header("content-type", "application/json"); + } + + auto parsed = boost::urls::parse_uri(config.base_url); + if (!parsed) { + return {"", network::HttpMethod::kGet, builder.Build(), + network::HttpRequest::BodyType{}}; + } + + boost::urls::url url = parsed.value(); + // A trailing '/' on the base URL appears as an empty final segment. + // Remove it so the pushed segments do not produce a double slash. + auto segments = url.segments(); + if (!segments.empty() && segments.back().empty()) { + segments.pop_back(); + } + segments.push_back("sdk"); + segments.push_back("poll"); + segments.push_back("eval"); + if (!post) { + segments.push_back( + encoding::Base64UrlEncode(config.serialized_context)); + } + + if (selector.value) { + url.params().append({"basis", selector.value->state}); + } + if (config.with_reasons) { + url.params().append({"withReasons", "true"}); + } + + return {std::string(url.buffer()), + post ? network::HttpMethod::kPost : network::HttpMethod::kGet, + builder.Build(), + post ? network::HttpRequest::BodyType{config.serialized_context} + : network::HttpRequest::BodyType{}}; +} + +static FDv2SourceResult ParseFDv2PollEvents( + boost::json::array const& events, + FDv2ProtocolHandler* protocol_handler, + Logger const& logger) { + for (auto const& event_val : events) { + auto const* event_obj = event_val.if_object(); + if (!event_obj) { + continue; + } + + auto const* event_type_val = event_obj->if_contains("event"); + auto const* event_data_val = event_obj->if_contains("data"); + if (!event_type_val || !event_data_val) { + continue; + } + + auto const* event_type_str = event_type_val->if_string(); + if (!event_type_str) { + continue; + } + + auto result = protocol_handler->HandleEvent( + std::string_view{event_type_str->data(), event_type_str->size()}, + *event_data_val); + + if (auto* change_set = + std::get_if(&result)) { + auto typed = TranslateChangeSet(*change_set, logger); + if (!typed) { + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorTranslation)}}; + } + return FDv2SourceResult{ + FDv2SourceResult::ChangeSet{std::move(*typed)}}; + } + if (auto* goodbye = std::get_if(&result)) { + FDv2SourceResult goodbye_result{ + FDv2SourceResult::Goodbye{goodbye->reason}}; + if (goodbye->protocol_fallback_ttl) { + goodbye_result.fdv1_fallback = + FDv1FallbackDirective::FromServiceTtl( + std::chrono::seconds(*goodbye->protocol_fallback_ttl)); + } + return goodbye_result; + } + if (auto* error = std::get_if(&result)) { + if (error->kind == FDv2ProtocolHandler::Error::Kind::kServerError) { + std::string id; + if (error->server_error) { + id = error->server_error->id.value_or(""); + } + std::string msg = + "An issue was encountered receiving updates for " + "payload '" + + id + "' with reason: '" + error->message + + "'. Automatic retry will occur."; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kErrorResponse, 0, std::move(msg))}}; + } + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, error->message)}}; + } + } + + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorIncompletePayload)}}; +} + +static FDv2SourceResult ParseFDv2PollResponse( + std::string const& body, + FDv2ProtocolHandler* protocol_handler, + Logger const& logger) { + boost::system::error_code ec; + auto parsed = boost::json::parse(body, ec); + if (ec) { + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody)}}; + } + + auto const* obj = parsed.if_object(); + if (!obj) { + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody)}}; + } + + auto const* events_val = obj->if_contains("events"); + if (!events_val) { + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents)}}; + } + + auto const* events_arr = events_val->if_array(); + if (!events_arr) { + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents)}}; + } + + return ParseFDv2PollEvents(*events_arr, protocol_handler, logger); +} + +FDv2SourceResult HandleFDv2PollResponse(network::HttpResult const& res, + FDv2ProtocolHandler* protocol_handler, + Logger const& logger, + std::string_view identity) { + if (res.IsError()) { + auto const& msg = res.ErrorMessage(); + std::string error_msg = msg.has_value() ? *msg : "unknown error"; + LD_LOG(logger, LogLevel::kWarn) << identity << ": " << error_msg; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kNetworkError, 0, std::move(error_msg))}}; + } + + auto headers = ReadFDv2ResponseHeaders(res.Headers()); + FDv2SourceResult result; + + if (res.Status() == 304) { + result = FDv2SourceResult{FDv2SourceResult::ChangeSet{FlagChangeSet{ + data_model::ChangeSetType::kNone, {}, data_model::Selector{}}}}; + } else if (res.Status() == 200) { + auto const& body = res.Body(); + if (!body) { + result = FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, + "FDv2 polling response contained no body")}}; + } else { + result = ParseFDv2PollResponse(*body, protocol_handler, logger); + if (auto* interrupted = + std::get_if(&result.value)) { + if (interrupted->error.Kind() == ErrorKind::kErrorResponse) { + LD_LOG(logger, LogLevel::kInfo) + << identity << ": " << interrupted->error.Message(); + } else { + LD_LOG(logger, LogLevel::kError) + << identity << ": " << interrupted->error.Message(); + } + } + } + } else if (network::IsRecoverableStatus(res.Status())) { + std::string msg = network::ErrorForStatusCode( + res.Status(), "FDv2 polling request", "will retry"); + LD_LOG(logger, LogLevel::kWarn) << identity << ": " << msg; + result = FDv2SourceResult{FDv2SourceResult::Interrupted{MakeError( + ErrorKind::kErrorResponse, res.Status(), std::move(msg))}}; + } else { + std::string msg = network::ErrorForStatusCode( + res.Status(), "FDv2 polling request", std::nullopt); + LD_LOG(logger, LogLevel::kError) << identity << ": " << msg; + result = FDv2SourceResult{FDv2SourceResult::TerminalError{MakeError( + ErrorKind::kErrorResponse, res.Status(), std::move(msg))}}; + } + + // A directive parsed from the response body, such as one on a goodbye + // message, takes precedence over the response header. + if (!result.fdv1_fallback) { + result.fdv1_fallback = std::move(headers.fdv1_fallback); + } + result.environment_id = std::move(headers.environment_id); + return result; +} + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/fdv2/fdv2_polling_impl.hpp b/libs/client-sdk/src/data_sources/fdv2/fdv2_polling_impl.hpp new file mode 100644 index 000000000..2fe7695f9 --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/fdv2_polling_impl.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "fdv2_request_config.hpp" +#include "fdv2_source_result.hpp" + +#include +#include +#include +#include + +#include + +namespace launchdarkly::client_side::data_sources { + +/** + * Builds the request to the FDv2 client polling endpoint. + */ +network::HttpRequest MakeFDv2PollRequest(FDv2RequestConfig const& config, + data_model::Selector const& selector); + +/** + * Interprets a response from the FDv2 polling endpoint, feeding its events + * through the protocol handler. + * + * @param protocol_handler Accumulates the response's events. A poll is a + * complete transfer cycle, so callers pass a handler used for this response + * alone. + * @param logger Receives a description of any failure. + * @param identity Names the caller in log messages. + */ +FDv2SourceResult HandleFDv2PollResponse(network::HttpResult const& res, + FDv2ProtocolHandler* protocol_handler, + Logger const& logger, + std::string_view identity); + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/fdv2/fdv2_request_config.hpp b/libs/client-sdk/src/data_sources/fdv2/fdv2_request_config.hpp new file mode 100644 index 000000000..f1f691483 --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/fdv2_request_config.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include + +#include + +namespace launchdarkly::client_side::data_sources { + +/** + * How the evaluation context reaches the service on an FDv2 request. + */ +enum class FDv2ContextTransport { + /** Base64url-encoded into the request path. The default. */ + kGetPath, + /** + * Serialized into the request body, keeping the context out of URL-based + * request logs, CDN logs, and browser history. + */ + kPostBody, +}; + +/** + * The parts of an FDv2 request that do not change between calls to a source. + * Evaluation context is included, because a source is built for a single + * context and replaced when the context changes. + */ +struct FDv2RequestConfig { + std::string base_url; + config::shared::built::HttpProperties http_properties; + std::string serialized_context; + FDv2ContextTransport transport; + bool with_reasons; +}; + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/fdv2/fdv2_response_headers.cpp b/libs/client-sdk/src/data_sources/fdv2/fdv2_response_headers.cpp new file mode 100644 index 000000000..30f25f019 --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/fdv2_response_headers.cpp @@ -0,0 +1,60 @@ +#include "fdv2_response_headers.hpp" + +#include + +namespace launchdarkly::client_side::data_sources { + +static char const* const kEnvironmentIdHeader = "X-LD-EnvId"; +static char const* const kFDv1FallbackHeader = "X-LD-FD-Fallback"; +static char const* const kFDv1FallbackTtlHeader = "X-LD-FD-Fallback-TTL"; + +FDv2ResponseHeaders ReadFDv2ResponseHeaders( + network::HttpResult::HeadersType const& headers) { + FDv2ResponseHeaders result; + + if (auto const it = headers.find(kEnvironmentIdHeader); + it != headers.end()) { + result.environment_id = it->second; + } + + auto const fallback = headers.find(kFDv1FallbackHeader); + if (fallback == headers.end() || + !boost::iequals(fallback->second, "true")) { + return result; + } + + auto const ttl = headers.find(kFDv1FallbackTtlHeader); + result.fdv1_fallback = + ttl == headers.end() + ? FDv1FallbackDirective::DefaultTtl() + : FDv1FallbackDirective::FromServiceTtl(ttl->second); + + return result; +} + +FDv2ResponseHeaders ReadFDv2ResponseHeaders( + boost::beast::http::response_header<> const& headers) { + FDv2ResponseHeaders result; + + if (auto const it = headers.find(kEnvironmentIdHeader); + it != headers.end()) { + result.environment_id = std::string{it->value()}; + } + + auto const fallback = headers.find(kFDv1FallbackHeader); + if (fallback == headers.end() || + !boost::iequals(fallback->value(), "true")) { + return result; + } + + auto const ttl = headers.find(kFDv1FallbackTtlHeader); + result.fdv1_fallback = + ttl == headers.end() + ? FDv1FallbackDirective::DefaultTtl() + : FDv1FallbackDirective::FromServiceTtl( + std::string_view{ttl->value().data(), ttl->value().size()}); + + return result; +} + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/fdv2/fdv2_response_headers.hpp b/libs/client-sdk/src/data_sources/fdv2/fdv2_response_headers.hpp new file mode 100644 index 000000000..4504a7a9d --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/fdv2_response_headers.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "fdv2_source_result.hpp" + +#include + +#include + +#include +#include + +namespace launchdarkly::client_side::data_sources { + +/** Values read from an FDv2 response's headers. */ +struct FDv2ResponseHeaders { + /** The environment the payload was evaluated in. */ + std::optional environment_id; + /** Set when the service directed the SDK back to FDv1. */ + std::optional fdv1_fallback; +}; + +FDv2ResponseHeaders ReadFDv2ResponseHeaders( + network::HttpResult::HeadersType const& headers); + +FDv2ResponseHeaders ReadFDv2ResponseHeaders( + boost::beast::http::response_header<> const& headers); + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/fdv2/fdv2_source_result.cpp b/libs/client-sdk/src/data_sources/fdv2/fdv2_source_result.cpp new file mode 100644 index 000000000..cfeceadde --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/fdv2_source_result.cpp @@ -0,0 +1,46 @@ +#include "fdv2_source_result.hpp" + +#include +#include +#include + +namespace launchdarkly::client_side::data_sources { + +namespace { + +// Subtracts a value drawn uniformly from [0, ttl/2], so that SDKs which fell +// back within the same window do not all re-attempt FDv2 at once. +std::chrono::seconds Jitter(std::chrono::seconds ttl) { + static thread_local std::mt19937_64 generator{std::random_device{}()}; + std::uniform_int_distribution distribution( + 0, ttl.count() / 2); + return ttl - std::chrono::seconds(distribution(generator)); +} + +} // namespace + +FDv1FallbackDirective FDv1FallbackDirective::DefaultTtl() { + return FDv1FallbackDirective{Jitter(kDefaultTtl)}; +} + +FDv1FallbackDirective FDv1FallbackDirective::FromServiceTtl( + std::chrono::seconds ttl) { + if (ttl > std::chrono::seconds::zero() && ttl <= kDefaultTtl) { + return FDv1FallbackDirective{ttl}; + } + return DefaultTtl(); +} + +FDv1FallbackDirective FDv1FallbackDirective::FromServiceTtl( + std::string_view ttl) { + std::uint64_t seconds = 0; + auto const* begin = ttl.data(); + auto const* end = begin + ttl.size(); + auto const [ptr, ec] = std::from_chars(begin, end, seconds); + if (ec != std::errc{} || ptr != end) { + return DefaultTtl(); + } + return FromServiceTtl(std::chrono::seconds(seconds)); +} + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/fdv2/fdv2_source_result.hpp b/libs/client-sdk/src/data_sources/fdv2/fdv2_source_result.hpp new file mode 100644 index 000000000..135f2fe4e --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/fdv2_source_result.hpp @@ -0,0 +1,91 @@ +#pragma once + +#include "../data_source_update_sink.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace launchdarkly::client_side::data_sources { + +/** + * An instruction from the service to stop using FDv2 and fall back to FDv1. + */ +struct FDv1FallbackDirective { + /** Used whenever the service supplies no usable TTL. */ + static constexpr std::chrono::seconds kDefaultTtl = std::chrono::hours(1); + + /** + * Builds a directive using the jittered default TTL, so that SDKs which + * fell back together do not all retry at once. Used when the service + * supplies no usable TTL. Safe to call from any thread. + */ + static FDv1FallbackDirective DefaultTtl(); + + /** + * Builds a directive from a service-supplied TTL, used as given. A value + * outside (0, 1 hour] uses the jittered default instead, so a fallback is + * never indefinite. + */ + static FDv1FallbackDirective FromServiceTtl(std::chrono::seconds ttl); + + /** + * Builds a directive from the raw value of a TTL response header. A value + * that is not a whole number of seconds uses the default. + */ + static FDv1FallbackDirective FromServiceTtl(std::string_view ttl); + + /** How long to stay off FDv2 before attempting to recover to it. */ + std::chrono::seconds ttl; +}; + +/** + * What an initializer or synchronizer produced: either flag data to apply, or + * a signal about the source's own state. + */ +struct FDv2SourceResult { + using ErrorInfo = common::data_sources::DataSourceStatusErrorInfo; + + /** A changeset was received and is ready to apply. */ + struct ChangeSet { + FlagChangeSet change_set; + }; + + /** A transient error occurred. The source may recover. */ + struct Interrupted { + ErrorInfo error; + }; + + /** A non-recoverable error occurred. The source should not be retried. */ + struct TerminalError { + ErrorInfo error; + }; + + /** The source was closed cleanly. */ + struct Shutdown {}; + + /** The service sent a goodbye. The orchestrator should rotate sources. */ + struct Goodbye { + std::optional reason; + }; + + using Value = + std::variant; + + Value value; + + /** Set if the underlying transport observed an FDv1 fallback directive. */ + std::optional fdv1_fallback; + + /** + * The environment the service reported this result was evaluated in, if + * the underlying transport could observe it. + */ + std::optional environment_id; +}; + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/fdv2/ifdv2_initializer.hpp b/libs/client-sdk/src/data_sources/fdv2/ifdv2_initializer.hpp new file mode 100644 index 000000000..704dfe4bf --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/ifdv2_initializer.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include "fdv2_source_result.hpp" + +#include + +#include + +namespace launchdarkly::client_side::data_sources { + +/** + * A one-shot data source that runs to completion and returns a single result, + * used to load a basis before handing off to an IFDv2Synchronizer. + * + * Implementations must be thread-safe to the extent this contract needs: + * Run() is called at most once, and Close() may be called concurrently with + * it from another thread. + */ +class IFDv2Initializer { + public: + /** + * Returns a Future that resolves with the result once the initializer + * completes. Called at most once per instance. + * + * Close() may be called from another thread to unblock Run(), in which + * case the future resolves with FDv2SourceResult::Shutdown. + */ + virtual async::Future Run() = 0; + + /** + * Unblocks any in-progress Run() call, causing it to return + * FDv2SourceResult::Shutdown. + */ + virtual void Close() = 0; + + /** + * @return A display-suitable name of the initializer. + */ + [[nodiscard]] virtual std::string const& Identity() const = 0; + + virtual ~IFDv2Initializer() = default; + IFDv2Initializer(IFDv2Initializer const&) = delete; + IFDv2Initializer(IFDv2Initializer&&) = delete; + IFDv2Initializer& operator=(IFDv2Initializer const&) = delete; + IFDv2Initializer& operator=(IFDv2Initializer&&) = delete; + + protected: + IFDv2Initializer() = default; +}; + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/fdv2/ifdv2_initializer_factory.hpp b/libs/client-sdk/src/data_sources/fdv2/ifdv2_initializer_factory.hpp new file mode 100644 index 000000000..462c5fb13 --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/ifdv2_initializer_factory.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "ifdv2_initializer.hpp" + +#include + +namespace launchdarkly::client_side::data_sources { + +/** + * Builds new IFDv2Initializer instances on demand. Each call to Build() + * produces a fresh initializer that has not yet been started. + * + * Implementations must be thread-safe. Build() and IsFromCache() may be + * called from any thread. + */ +class IFDv2InitializerFactory { + public: + virtual std::unique_ptr Build() = 0; + + /** + * Whether the initializers this factory builds read from the local cache + * rather than the network. + */ + [[nodiscard]] virtual bool IsFromCache() const { return false; } + + virtual ~IFDv2InitializerFactory() = default; + IFDv2InitializerFactory(IFDv2InitializerFactory const&) = delete; + IFDv2InitializerFactory(IFDv2InitializerFactory&&) = delete; + IFDv2InitializerFactory& operator=(IFDv2InitializerFactory const&) = delete; + IFDv2InitializerFactory& operator=(IFDv2InitializerFactory&&) = delete; + + protected: + IFDv2InitializerFactory() = default; +}; + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/fdv2/ifdv2_synchronizer.hpp b/libs/client-sdk/src/data_sources/fdv2/ifdv2_synchronizer.hpp new file mode 100644 index 000000000..ee86065de --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/ifdv2_synchronizer.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include "fdv2_source_result.hpp" + +#include +#include + +#include + +namespace launchdarkly::client_side::data_sources { + +/** + * A continuous data source that produces a stream of results, used to keep + * flag data current once initialization is complete. + * + * The underlying connection is started lazily on the first call to Next() and + * runs until Close() is called. + * + * Implementations must be thread-safe to the extent this contract needs: + * Next() is called from one thread at a time, and Close() may be called + * concurrently with it from another. + */ +class IFDv2Synchronizer { + public: + /** + * Returns a Future that resolves with the next result once it is + * available. + * + * On the first call, the synchronizer starts its underlying connection. + * Subsequent calls continue reading from the same connection. + * + * Close() may be called from another thread to unblock Next(), in which + * case the future resolves with FDv2SourceResult::Shutdown. + * + * @param selector The selector to send with the request, reflecting any + * changesets applied since the previous call. An empty selector asks for + * a full data set. + */ + virtual async::Future Next( + data_model::Selector selector) = 0; + + /** + * Unblocks any in-progress Next() call, causing it to return + * FDv2SourceResult::Shutdown, and releases underlying resources. + */ + virtual void Close() = 0; + + /** + * @return A display-suitable name of the synchronizer. + */ + [[nodiscard]] virtual std::string const& Identity() const = 0; + + virtual ~IFDv2Synchronizer() = default; + IFDv2Synchronizer(IFDv2Synchronizer const&) = delete; + IFDv2Synchronizer(IFDv2Synchronizer&&) = delete; + IFDv2Synchronizer& operator=(IFDv2Synchronizer const&) = delete; + IFDv2Synchronizer& operator=(IFDv2Synchronizer&&) = delete; + + protected: + IFDv2Synchronizer() = default; +}; + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/fdv2/ifdv2_synchronizer_factory.hpp b/libs/client-sdk/src/data_sources/fdv2/ifdv2_synchronizer_factory.hpp new file mode 100644 index 000000000..7cd7b3d8a --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/ifdv2_synchronizer_factory.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "ifdv2_synchronizer.hpp" + +#include + +namespace launchdarkly::client_side::data_sources { + +/** + * Builds new IFDv2Synchronizer instances on demand. Each call to Build() + * produces a fresh synchronizer that has not yet been started. + * + * Implementations must be thread-safe. Build() and IsFDv1Fallback() may be + * called from any thread. + */ +class IFDv2SynchronizerFactory { + public: + virtual std::unique_ptr Build() = 0; + + /** + * Whether the synchronizers this factory builds speak FDv1. + */ + [[nodiscard]] virtual bool IsFDv1Fallback() const { return false; } + + virtual ~IFDv2SynchronizerFactory() = default; + IFDv2SynchronizerFactory(IFDv2SynchronizerFactory const&) = delete; + IFDv2SynchronizerFactory(IFDv2SynchronizerFactory&&) = delete; + IFDv2SynchronizerFactory& operator=(IFDv2SynchronizerFactory const&) = + delete; + IFDv2SynchronizerFactory& operator=(IFDv2SynchronizerFactory&&) = delete; + + protected: + IFDv2SynchronizerFactory() = default; +}; + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/fdv2/polling_initializer.cpp b/libs/client-sdk/src/data_sources/fdv2/polling_initializer.cpp new file mode 100644 index 000000000..c73f0bade --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/polling_initializer.cpp @@ -0,0 +1,75 @@ +#include "polling_initializer.hpp" +#include "fdv2_polling_impl.hpp" + +#include +#include + +#include + +namespace launchdarkly::client_side::data_sources { + +static char const* const kIdentity = "FDv2 polling initializer"; + +FDv2PollingInitializer::FDv2PollingInitializer( + boost::asio::any_io_executor const& executor, + Logger const& logger, + FDv2RequestConfig const& request_config) + : logger_(logger), + request_(MakeFDv2PollRequest(request_config, data_model::Selector{})), + requester_(executor, request_config.http_properties.Tls()) {} + +FDv2PollingInitializer::~FDv2PollingInitializer() { + close_promise_.Resolve(std::monostate{}); +} + +async::Future FDv2PollingInitializer::Run() { + if (!request_.Valid()) { + LD_LOG(logger_, LogLevel::kError) + << kIdentity << ": invalid polling endpoint URL"; + using ErrorInfo = FDv2SourceResult::ErrorInfo; + return async::MakeFuture( + FDv2SourceResult{FDv2SourceResult::TerminalError{ + ErrorInfo{ErrorInfo::ErrorKind::kNetworkError, 0, + "invalid polling endpoint URL", + std::chrono::system_clock::now()}}}); + } + + // The promise must be in a shared_ptr because Requester requires + // copy-constructible callbacks. + auto http_promise = std::make_shared>(); + auto http_future = http_promise->GetFuture(); + requester_.Request(request_, [hp = std::move(http_promise)]( + network::HttpResult const& res) mutable { + hp->Resolve(res); + }); + + // WhenAny reports index 0 for the HTTP result and 1 for close. + return async::WhenAny(http_future, close_promise_.GetFuture()) + .Then( + [logger = logger_, http_future = std::move(http_future)]( + std::size_t const& idx) -> FDv2SourceResult { + if (idx == 1) { + return FDv2SourceResult{FDv2SourceResult::Shutdown{}}; + } + return HandlePollResult(logger, *http_future.GetResult()); + }, + async::kInlineExecutor); +} + +void FDv2PollingInitializer::Close() { + close_promise_.Resolve(std::monostate{}); +} + +std::string const& FDv2PollingInitializer::Identity() const { + static std::string const identity = kIdentity; + return identity; +} + +FDv2SourceResult FDv2PollingInitializer::HandlePollResult( + Logger const& logger, + network::HttpResult const& res) { + FDv2ProtocolHandler protocol_handler; + return HandleFDv2PollResponse(res, &protocol_handler, logger, kIdentity); +} + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/fdv2/polling_initializer.hpp b/libs/client-sdk/src/data_sources/fdv2/polling_initializer.hpp new file mode 100644 index 000000000..702e5c030 --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/polling_initializer.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include "fdv2_request_config.hpp" +#include "ifdv2_initializer.hpp" + +#include +#include +#include + +#include + +#include +#include + +namespace launchdarkly::client_side::data_sources { + +/** + * Loads a basis with a single request to the FDv2 client polling endpoint. + * + * Threading model: + * Run() should only be called once at a time. + * Close() may be called concurrently with Run(). + * This object may be safely destroyed once no call to Run() or Close() is + * in progress. + */ +class FDv2PollingInitializer final : public IFDv2Initializer { + public: + FDv2PollingInitializer(boost::asio::any_io_executor const& executor, + Logger const& logger, + FDv2RequestConfig const& request_config); + + ~FDv2PollingInitializer() override; + + async::Future Run() override; + + void Close() override; + + [[nodiscard]] std::string const& Identity() const override; + + private: + /** Interprets an HTTP response as a source result. */ + static FDv2SourceResult HandlePollResult(Logger const& logger, + network::HttpResult const& res); + + // Logger is itself thread-safe and cheap to copy. + Logger const logger_; + + // Immutable state. + network::HttpRequest const request_; + network::Requester const requester_; + + // Resolved when Close() is called, or when this object is destroyed, + // cancelling any outstanding Run(). + async::Promise close_promise_; +}; + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/fdv2/polling_synchronizer.cpp b/libs/client-sdk/src/data_sources/fdv2/polling_synchronizer.cpp new file mode 100644 index 000000000..f56dd3407 --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/polling_synchronizer.cpp @@ -0,0 +1,163 @@ +#include "polling_synchronizer.hpp" +#include "fdv2_polling_impl.hpp" + +#include +#include +#include + +#include +#include + +namespace launchdarkly::client_side::data_sources { + +static char const* const kIdentity = "FDv2 polling synchronizer"; + +static std::chrono::seconds MinPollInterval() { + return config::shared::Defaults::PollingConfig() + .min_polling_interval; +} + +FDv2PollingSynchronizer::State::State( + Logger const& logger, + boost::asio::any_io_executor const& executor, + FDv2RequestConfig const& request_config, + std::chrono::seconds poll_interval, + std::optional last_poll) + : logger_(logger), + poll_interval_(std::max(poll_interval, MinPollInterval())), + request_config_(request_config), + requester_(executor, request_config.http_properties.Tls()), + executor_(executor), + last_poll_start_(last_poll) {} + +async::Future FDv2PollingSynchronizer::State::Request( + data_model::Selector const& selector) const { + auto request = MakeFDv2PollRequest(request_config_, selector); + + // The promise must be in a shared_ptr because Requester requires + // copy-constructible callbacks. + auto promise = std::make_shared>(); + auto future = promise->GetFuture(); + requester_.Request(request, [promise = std::move(promise)]( + network::HttpResult const& res) mutable { + promise->Resolve(res); + }); + return future; +} + +FDv2SourceResult FDv2PollingSynchronizer::State::HandlePollResult( + network::HttpResult const& res) const { + FDv2ProtocolHandler protocol_handler; + return HandleFDv2PollResponse(res, &protocol_handler, logger_, kIdentity); +} + +async::Future FDv2PollingSynchronizer::State::AwaitNextPoll( + async::CancellationToken token) { + std::lock_guard lock(mutex_); + + if (!last_poll_start_) { + return async::MakeFuture(true); + } + auto const elapsed = std::chrono::steady_clock::now() - *last_poll_start_; + if (elapsed >= poll_interval_) { + return async::MakeFuture(true); + } + return async::Delay(executor_, poll_interval_ - elapsed, std::move(token)); +} + +void FDv2PollingSynchronizer::State::RecordPollStarted() { + std::lock_guard lock(mutex_); + + last_poll_start_ = std::chrono::steady_clock::now(); +} + +FDv2PollingSynchronizer::FDv2PollingSynchronizer( + boost::asio::any_io_executor const& executor, + Logger const& logger, + FDv2RequestConfig const& request_config, + std::chrono::seconds poll_interval, + std::optional last_poll) + : state_(std::make_shared(logger, + executor, + request_config, + poll_interval, + last_poll)) { + if (poll_interval < MinPollInterval()) { + LD_LOG(logger, LogLevel::kWarn) + << kIdentity << ": polling interval too frequent, defaulting to " + << MinPollInterval().count() << " seconds"; + } +} + +FDv2PollingSynchronizer::~FDv2PollingSynchronizer() { + Close(); +} + +async::Future FDv2PollingSynchronizer::Next( + data_model::Selector selector) { + return DoNext(state_, close_promise_.GetFuture(), std::move(selector)); +} + +void FDv2PollingSynchronizer::Close() { + close_promise_.Resolve(std::monostate{}); +} + +std::string const& FDv2PollingSynchronizer::Identity() const { + static std::string const identity = kIdentity; + return identity; +} + +/* static */ async::Future FDv2PollingSynchronizer::DoNext( + std::shared_ptr state, + async::Future closed, + data_model::Selector selector) { + if (closed.IsFinished()) { + return async::MakeFuture( + FDv2SourceResult{FDv2SourceResult::Shutdown{}}); + } + + async::CancellationSource cancel; + auto delay_future = state->AwaitNextPoll(cancel.GetToken()); + + return async::WhenAny(closed, std::move(delay_future)) + .Then( + [state = std::move(state), closed = std::move(closed), + selector = std::move(selector), + cancel = std::move(cancel)](std::size_t const& idx) mutable + -> async::Future { + cancel.Cancel(); + if (idx == 0) { + return async::MakeFuture( + FDv2SourceResult{FDv2SourceResult::Shutdown{}}); + } + return DoPoll(std::move(state), std::move(closed), selector); + }, + async::kInlineExecutor); +} + +/* static */ async::Future FDv2PollingSynchronizer::DoPoll( + std::shared_ptr state, + async::Future closed, + data_model::Selector const& selector) { + if (closed.IsFinished()) { + return async::MakeFuture( + FDv2SourceResult{FDv2SourceResult::Shutdown{}}); + } + + state->RecordPollStarted(); + + auto http_future = state->Request(selector); + + return async::WhenAny(std::move(closed), http_future) + .Then( + [state = std::move(state), http_future = std::move(http_future)]( + std::size_t const& idx) mutable -> FDv2SourceResult { + if (idx == 0) { + return FDv2SourceResult{FDv2SourceResult::Shutdown{}}; + } + return state->HandlePollResult(*http_future.GetResult()); + }, + async::kInlineExecutor); +} + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/fdv2/polling_synchronizer.hpp b/libs/client-sdk/src/data_sources/fdv2/polling_synchronizer.hpp new file mode 100644 index 000000000..652eb87d0 --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/polling_synchronizer.hpp @@ -0,0 +1,132 @@ +#pragma once + +#include "fdv2_request_config.hpp" +#include "ifdv2_synchronizer.hpp" + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace launchdarkly::client_side::data_sources { + +/** + * Keeps flag data current by polling the FDv2 client polling endpoint on an + * interval. + * + * Polls are rate limited. A poll that would come sooner than the interval + * allows is delayed by the time remaining, so that repeated activations of + * this synchronizer cannot produce a burst of requests. + * + * Threading model: + * Next() should only be called once at a time. + * Close() may be called concurrently with Next(). + * This object may be safely destroyed once no call to Next() or Close() is + * in progress. + */ +class FDv2PollingSynchronizer final : public IFDv2Synchronizer { + public: + /** + * @param executor Runs the HTTP requests and the interval timer. + * @param logger Receives a description of any failure. + * @param request_config How to reach the polling endpoint, and which + * context to evaluate. + * @param poll_interval How long to wait between polls. Clamped up to the + * minimum the synchronizer permits. + * @param last_poll The time of the most recent poll for this context, if + * one is known, so that the first poll respects the interval too. + */ + FDv2PollingSynchronizer( + boost::asio::any_io_executor const& executor, + Logger const& logger, + FDv2RequestConfig const& request_config, + std::chrono::seconds poll_interval, + std::optional last_poll); + + ~FDv2PollingSynchronizer() override; + + async::Future Next( + data_model::Selector selector) override; + + void Close() override; + + [[nodiscard]] std::string const& Identity() const override; + + private: + // Any state that async callbacks may touch lives here, held by + // shared_ptr so those callbacks can outlive the synchronizer. + class State { + public: + State(Logger const& logger, + boost::asio::any_io_executor const& executor, + FDv2RequestConfig const& request_config, + std::chrono::seconds poll_interval, + std::optional last_poll); + + /** Issues an async poll, resolving with the HTTP response. */ + [[nodiscard]] async::Future Request( + data_model::Selector const& selector) const; + + /** Interprets an HTTP response as a source result. */ + FDv2SourceResult HandlePollResult(network::HttpResult const& res) const; + + /** + * Returns a Future that resolves when the interval permits the next + * poll, or early with false if the token is cancelled first. + */ + [[nodiscard]] async::Future AwaitNextPoll( + async::CancellationToken token); + + /** Records that a poll has started, for interval scheduling. */ + void RecordPollStarted(); + + private: + // Logger is itself thread-safe. + Logger const logger_; + + // Immutable state. + std::chrono::seconds const poll_interval_; + FDv2RequestConfig const request_config_; + network::Requester const requester_; + boost::asio::any_io_executor const executor_; + + std::mutex mutex_; + // Protected by mutex_. + std::optional last_poll_start_; + }; + + /** + * Waits for the poll interval, then delegates to DoPoll. Resolves with + * Shutdown if closed before the next poll begins. + */ + static async::Future DoNext( + std::shared_ptr state, + async::Future closed, + data_model::Selector selector); + + /** + * Issues a single poll and returns the result. Resolves with Shutdown if + * closed before the request completes. + */ + static async::Future DoPoll( + std::shared_ptr state, + async::Future closed, + data_model::Selector const& selector); + + // Resolved by Close() or on destruction, cancelling any outstanding + // Next() call. + async::Promise close_promise_; + + // Shared with async callbacks. + std::shared_ptr state_; +}; + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/fdv2/streaming_synchronizer.cpp b/libs/client-sdk/src/data_sources/fdv2/streaming_synchronizer.cpp new file mode 100644 index 000000000..2720b39cc --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/streaming_synchronizer.cpp @@ -0,0 +1,472 @@ +#include "streaming_synchronizer.hpp" +#include "fdv2_changeset_translation.hpp" +#include "fdv2_polling_impl.hpp" +#include "fdv2_response_headers.hpp" + +#include + +#include +#include + +#include + +namespace launchdarkly::client_side::data_sources { + +static char const* const kIdentity = "FDv2 streaming synchronizer"; + +static char const* const kPingEvent = "ping"; + +// Read-idle timeout for the long-lived stream, larger than the service +// heartbeat so a live connection is not declared dead. +static constexpr std::chrono::minutes kDeadConnectionInterval{5}; + +using ErrorInfo = FDv2SourceResult::ErrorInfo; +using ErrorKind = ErrorInfo::ErrorKind; + +static ErrorInfo MakeError(ErrorKind kind, + ErrorInfo::StatusCodeType status, + std::string message) { + return ErrorInfo{kind, status, std::move(message), + std::chrono::system_clock::now()}; +} + +template +inline constexpr bool always_false_v = false; + +FDv2StreamingSynchronizer::State::State( + Logger const& logger, + boost::asio::any_io_executor const& executor, + FDv2RequestConfig const& stream_config, + FDv2RequestConfig const& poll_config, + std::chrono::milliseconds initial_reconnect_delay) + : logger_(logger), + stream_config_(stream_config), + poll_config_(poll_config), + initial_reconnect_delay_(initial_reconnect_delay), + executor_(executor), + requester_(executor, poll_config.http_properties.Tls()) {} + +void FDv2StreamingSynchronizer::State::EnsureStarted( + data_model::Selector const& selector, + std::shared_ptr self) { + { + std::lock_guard lock(mutex_); + latest_selector_ = selector; + if (closed_ || started_) { + return; + } + started_ = true; + } + + bool const post = + stream_config_.transport == FDv2ContextTransport::kPostBody; + + auto parsed = boost::urls::parse_uri(stream_config_.base_url); + if (!parsed) { + // A bad endpoint URL is a configuration error that won't fix itself, + // so started_ stays true and this synchronizer does not reconnect. + LD_LOG(logger_, LogLevel::kError) + << kIdentity << ": could not parse streaming endpoint URL"; + Notify(FDv2SourceResult{FDv2SourceResult::TerminalError{ + MakeError(ErrorKind::kNetworkError, 0, + "could not parse streaming endpoint URL")}}); + return; + } + + boost::urls::url url = parsed.value(); + + // A trailing '/' on the base URL appears as an empty final segment. + // Remove it so the pushed segments do not produce a double slash. + auto segments = url.segments(); + if (!segments.empty() && segments.back().empty()) { + segments.pop_back(); + } + segments.push_back("sdk"); + segments.push_back("stream"); + segments.push_back("eval"); + if (!post) { + segments.push_back( + encoding::Base64UrlEncode(stream_config_.serialized_context)); + } + if (stream_config_.with_reasons) { + url.params().set("withReasons", "true"); + } + + // The basis parameter is added by the on-connect hook instead, so that + // each reconnection uses the freshest selector. + { + std::lock_guard lock(mutex_); + base_url_ = url; + } + + auto builder = sse::Builder(executor_, std::string(url.buffer())); + + builder.method(post ? boost::beast::http::verb::post + : boost::beast::http::verb::get); + if (post) { + builder.header("content-type", "application/json"); + builder.body(stream_config_.serialized_context); + } + builder.read_timeout(kDeadConnectionInterval); + builder.write_timeout(stream_config_.http_properties.WriteTimeout()); + builder.connect_timeout(stream_config_.http_properties.ConnectTimeout()); + builder.initial_reconnect_delay(initial_reconnect_delay_); + + for (auto const& [key, value] : + stream_config_.http_properties.BaseHeaders()) { + builder.header(key, value); + } + if (stream_config_.http_properties.Tls().PeerVerifyMode() == + config::shared::built::TlsOptions::VerifyMode::kVerifyNone) { + builder.skip_verify_peer(true); + } + if (auto ca_file = stream_config_.http_properties.Tls().CustomCAFile()) { + builder.custom_ca_file(*ca_file); + } + if (auto proxy_url = stream_config_.http_properties.Proxy().Url()) { + builder.proxy(*proxy_url); + } + + std::weak_ptr weak = self; + builder.on_connect([weak](HttpRequest* req) { + if (auto s = weak.lock()) { + s->OnConnect(req); + } + }); + builder.on_response([weak](HttpResponseHeader const& headers) { + if (auto s = weak.lock()) { + s->OnResponse(headers); + } + }); + builder.receiver([weak](sse::Event const& event) { + if (auto s = weak.lock()) { + s->OnEvent(event); + } + }); + builder.logger([weak](std::string msg) { + if (auto s = weak.lock()) { + LD_LOG(s->logger_, LogLevel::kDebug) << "sse-client: " << msg; + } + }); + builder.errors([weak](sse::Error error) { + if (auto s = weak.lock()) { + s->OnError(error); + } + }); + + auto client = builder.build(); + if (!client) { + LD_LOG(logger_, LogLevel::kError) + << kIdentity << ": could not build SSE client"; + Notify(FDv2SourceResult{FDv2SourceResult::TerminalError{MakeError( + ErrorKind::kNetworkError, 0, "could not build SSE client")}}); + return; + } + + // If Close() ran while we were building, drop the client and stop. + std::lock_guard lock(mutex_); + if (closed_) { + return; + } + sse_client_ = client; + client->async_connect(); +} + +void FDv2StreamingSynchronizer::State::OnConnect(HttpRequest* req) { + std::lock_guard lock(mutex_); + // base_url_ is guaranteed populated. EnsureStarted publishes it before + // calling async_connect, which is what eventually triggers this hook. + boost::urls::url url = *base_url_; + if (latest_selector_.value) { + url.params().set("basis", latest_selector_.value->state); + } + req->target(url.encoded_target()); +} + +void FDv2StreamingSynchronizer::State::OnResponse( + HttpResponseHeader const& headers) { + auto read = ReadFDv2ResponseHeaders(headers); + + std::lock_guard lock(mutex_); + latest_fdv1_fallback_ = std::move(read.fdv1_fallback); + if (read.environment_id) { + latest_environment_id_ = std::move(read.environment_id); + } +} + +void FDv2StreamingSynchronizer::State::PollForPing( + std::shared_ptr self) { + { + std::lock_guard lock(mutex_); + if (closed_ || ping_poll_in_flight_) { + return; + } + ping_poll_in_flight_ = true; + } + + LD_LOG(logger_, LogLevel::kDebug) + << kIdentity << ": ping received, polling for the current payload"; + + data_model::Selector selector; + { + std::lock_guard lock(mutex_); + selector = latest_selector_; + } + + auto request = MakeFDv2PollRequest(poll_config_, selector); + requester_.Request( + request, [self = std::move(self)](network::HttpResult const& res) { + FDv2ProtocolHandler handler; + auto result = + HandleFDv2PollResponse(res, &handler, self->logger_, kIdentity); + { + std::lock_guard lock(self->mutex_); + self->ping_poll_in_flight_ = false; + } + self->Notify(std::move(result)); + }); +} + +void FDv2StreamingSynchronizer::State::OnEvent(sse::Event const& event) { + if (event.type() == kPingEvent) { + PollForPing(shared_from_this()); + return; + } + + if (!FDv2ProtocolHandler::IsKnownEvent(event.type())) { + return; + } + + boost::system::error_code ec; + auto data = boost::json::parse(event.data(), ec); + if (ec) { + protocol_handler_.Reset(); + std::string msg = "could not parse FDv2 streaming event payload"; + LD_LOG(logger_, LogLevel::kError) << kIdentity << ": " << msg; + Notify(FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, std::move(msg))}}); + std::lock_guard lock(mutex_); + if (sse_client_) { + sse_client_->async_restart("FDv2 parse error"); + } + return; + } + + auto result = protocol_handler_.HandleEvent(event.type(), data); + + std::visit( + [this](auto const& r) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + // Accumulating, heartbeat, or unknown event -- nothing to do. + } else if constexpr (std::is_same_v) { + auto typed = TranslateChangeSet(r, logger_); + if (!typed) { + // Discard the accepted-but-unstored payload. + protocol_handler_.Reset(); + std::string msg = + "FDv2 streaming changeset could not be translated"; + LD_LOG(logger_, LogLevel::kError) + << kIdentity << ": " << msg; + Notify(FDv2SourceResult{ + FDv2SourceResult::Interrupted{MakeError( + ErrorKind::kInvalidData, 0, std::move(msg))}}); + std::lock_guard lock(mutex_); + if (sse_client_) { + sse_client_->async_restart("FDv2 translation error"); + } + return; + } + Notify(FDv2SourceResult{ + FDv2SourceResult::ChangeSet{std::move(*typed)}}); + } else if constexpr (std::is_same_v) { + LD_LOG(logger_, LogLevel::kInfo) + << kIdentity + << ": Goodbye was received from the LaunchDarkly " + "connection with reason: '" + << r.reason.value_or("") << "'."; + FDv2SourceResult goodbye_result{ + FDv2SourceResult::Goodbye{r.reason}}; + if (r.protocol_fallback_ttl) { + goodbye_result.fdv1_fallback = + FDv1FallbackDirective::FromServiceTtl( + std::chrono::seconds(*r.protocol_fallback_ttl)); + } + Notify(std::move(goodbye_result)); + // Drop the current connection and reconnect. The protocol + // handler is reset so the new connection starts in a clean + // state. + protocol_handler_.Reset(); + std::lock_guard lock(mutex_); + if (sse_client_) { + sse_client_->async_restart("FDv2 goodbye received"); + } + } else if constexpr (std::is_same_v) { + if (r.kind == FDv2ProtocolHandler::Error::Kind::kServerError) { + auto const& id = r.server_error.value().id; + std::string msg = + "An issue was encountered receiving updates for " + "payload '" + + id.value_or("") + "' with reason: '" + r.message + + "'. Automatic retry will occur."; + LD_LOG(logger_, LogLevel::kInfo) + << kIdentity << ": " << msg; + Notify(FDv2SourceResult{ + FDv2SourceResult::Interrupted{MakeError( + ErrorKind::kErrorResponse, 0, std::move(msg))}}); + return; + } + LD_LOG(logger_, LogLevel::kError) + << kIdentity << ": " << r.message; + Notify(FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, r.message)}}); + std::lock_guard lock(mutex_); + if (sse_client_) { + sse_client_->async_restart("FDv2 protocol error"); + } + } else { + static_assert(always_false_v, "non-exhaustive visitor"); + } + }, + result); +} + +void FDv2StreamingSynchronizer::State::OnError(sse::Error const& error) { + protocol_handler_.Reset(); + + std::string msg = sse::ErrorToString(error); + + if (sse::IsRecoverable(error)) { + LD_LOG(logger_, LogLevel::kWarn) << kIdentity << ": " << msg; + Notify(FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kNetworkError, 0, std::move(msg))}}); + return; + } + + LD_LOG(logger_, LogLevel::kError) << kIdentity << ": " << msg; + + if (auto const* client_error = + std::get_if(&error)) { + Notify(FDv2SourceResult{FDv2SourceResult::TerminalError{MakeError( + ErrorKind::kErrorResponse, + static_cast(client_error->status), + std::move(msg))}}); + return; + } + + Notify(FDv2SourceResult{FDv2SourceResult::TerminalError{ + MakeError(ErrorKind::kNetworkError, 0, std::move(msg))}}); +} + +void FDv2StreamingSynchronizer::State::Notify(FDv2SourceResult result) { + std::optional> promise; + { + std::lock_guard lock(mutex_); + // A directive parsed from the stream, such as one on a goodbye + // message, takes precedence over the most recent response header. + if (!result.fdv1_fallback) { + result.fdv1_fallback = latest_fdv1_fallback_; + } + if (!result.environment_id) { + result.environment_id = latest_environment_id_; + } + if (pending_promise_) { + promise = std::move(pending_promise_); + pending_promise_.reset(); + } else { + result_queue_.push_back(std::move(result)); + return; + } + } + // Resolve outside the lock. Promise::Resolve may invoke inline + // continuations that could call back into Notify or Next. + promise->Resolve(std::move(result)); +} + +async::Future FDv2StreamingSynchronizer::State::Next( + data_model::Selector const& selector, + std::shared_ptr self) { + EnsureStarted(selector, std::move(self)); + + std::lock_guard lock(mutex_); + if (!result_queue_.empty()) { + auto result = std::move(result_queue_.front()); + result_queue_.pop_front(); + return async::MakeFuture(std::move(result)); + } + return pending_promise_.emplace().GetFuture(); +} + +void FDv2StreamingSynchronizer::State::ClearPendingPromise() { + std::lock_guard lock(mutex_); + pending_promise_.reset(); +} + +void FDv2StreamingSynchronizer::State::Shutdown() { + std::shared_ptr client; + { + std::lock_guard lock(mutex_); + closed_ = true; + client = std::exchange(sse_client_, nullptr); + } + if (client) { + client->async_shutdown([] {}); + } +} + +FDv2StreamingSynchronizer::FDv2StreamingSynchronizer( + boost::asio::any_io_executor const& executor, + Logger const& logger, + FDv2RequestConfig const& stream_config, + FDv2RequestConfig const& poll_config, + std::chrono::milliseconds initial_reconnect_delay) + : state_(std::make_shared(logger, + executor, + stream_config, + poll_config, + initial_reconnect_delay)) {} + +FDv2StreamingSynchronizer::~FDv2StreamingSynchronizer() { + Close(); +} + +async::Future FDv2StreamingSynchronizer::Next( + data_model::Selector selector) { + auto closed = close_promise_.GetFuture(); + if (closed.IsFinished()) { + return async::MakeFuture( + FDv2SourceResult{FDv2SourceResult::Shutdown{}}); + } + + auto result_future = state_->Next(selector, state_); + if (result_future.IsFinished()) { + return result_future; + } + + return async::WhenAny(closed, result_future) + .Then( + [state = state_, result_future]( + std::size_t const& idx) mutable -> FDv2SourceResult { + if (idx == 0) { + state->ClearPendingPromise(); + return FDv2SourceResult{FDv2SourceResult::Shutdown{}}; + } + return *result_future.GetResult(); + }, + async::kInlineExecutor); +} + +void FDv2StreamingSynchronizer::Close() { + if (!close_promise_.Resolve(std::monostate{})) { + return; + } + state_->Shutdown(); +} + +std::string const& FDv2StreamingSynchronizer::Identity() const { + static std::string const identity = kIdentity; + return identity; +} + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/data_sources/fdv2/streaming_synchronizer.hpp b/libs/client-sdk/src/data_sources/fdv2/streaming_synchronizer.hpp new file mode 100644 index 000000000..7d16979fe --- /dev/null +++ b/libs/client-sdk/src/data_sources/fdv2/streaming_synchronizer.hpp @@ -0,0 +1,174 @@ +#pragma once + +#include "fdv2_request_config.hpp" +#include "ifdv2_synchronizer.hpp" + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace launchdarkly::client_side::data_sources { + +class FDv2StreamingSynchronizerTestPeer; + +/** + * Keeps flag data current over a long-lived connection to the FDv2 client + * streaming endpoint, turning the push-based event stream into the pull-based + * IFDv2Synchronizer::Next() interface. + * + * Threading model: + * Next() should only be called once at a time. + * Close() may be called concurrently with Next(). + * This object may be safely destroyed once no call to Next() or Close() is + * in progress. + */ +class FDv2StreamingSynchronizer final : public IFDv2Synchronizer { + friend class FDv2StreamingSynchronizerTestPeer; + + public: + /** + * @param executor Runs the stream, the ping-triggered polls, and the + * reconnection backoff. + * @param logger Receives a description of any failure. + * @param stream_config How to reach the streaming endpoint, and which + * context to evaluate. + * @param poll_config Where to poll in answer to a `ping` event. Must + * describe the same context as stream_config. + * @param initial_reconnect_delay Where the reconnection backoff starts. + */ + FDv2StreamingSynchronizer( + boost::asio::any_io_executor const& executor, + Logger const& logger, + FDv2RequestConfig const& stream_config, + FDv2RequestConfig const& poll_config, + std::chrono::milliseconds initial_reconnect_delay); + + ~FDv2StreamingSynchronizer() override; + + async::Future Next( + data_model::Selector selector) override; + + void Close() override; + + [[nodiscard]] std::string const& Identity() const override; + + private: + // Any state that async SSE callbacks may touch lives here, held by + // shared_ptr so those callbacks can outlive the synchronizer. + class State : public std::enable_shared_from_this { + friend class FDv2StreamingSynchronizerTestPeer; + + public: + State(Logger const& logger, + boost::asio::any_io_executor const& executor, + FDv2RequestConfig const& stream_config, + FDv2RequestConfig const& poll_config, + std::chrono::milliseconds initial_reconnect_delay); + + /** + * Records the selector to send on the next connection attempt, starts + * the stream if it is not already running, and returns a Future + * resolving with the next result. + * + * If a result is already buffered the Future is resolved + * immediately. Otherwise it resolves when the next event arrives. + * + * @param self The shared_ptr owning this State, used to form the weak + * references the SSE callbacks capture. + */ + async::Future Next( + data_model::Selector const& selector, + std::shared_ptr self); + + /** + * Abandons an outstanding Next() call without delivering a result. + * Any result that arrives afterwards is buffered for the next call. + */ + void ClearPendingPromise(); + + /** + * Marks the State closed and shuts down the stream if one was + * started. After Shutdown returns, no new stream can start. + * Idempotent. + */ + void Shutdown(); + + private: + using HttpRequest = + boost::beast::http::request; + using HttpResponseHeader = boost::beast::http::response_header<>; + + /** Starts the stream if it is not already running. */ + void EnsureStarted(data_model::Selector const& selector, + std::shared_ptr self); + + /** + * Delivers a result to the caller of Next(), or buffers it if no + * caller is waiting. + */ + void Notify(FDv2SourceResult result); + + /** + * Issues the poll a `ping` event calls for, delivering its result + * when it arrives. A ping received while an answering poll is still + * in flight is dropped, so that a burst of pings cannot pile up + * requests. + */ + void PollForPing(std::shared_ptr self); + + // SSE client callbacks. + void OnConnect(HttpRequest* req); + void OnResponse(HttpResponseHeader const& headers); + void OnEvent(sse::Event const& event); + void OnError(sse::Error const& error); + + // Logger is itself thread-safe. + Logger const logger_; + + // Immutable state. + FDv2RequestConfig const stream_config_; + FDv2RequestConfig const poll_config_; + std::chrono::milliseconds const initial_reconnect_delay_; + boost::asio::any_io_executor const executor_; + network::Requester const requester_; + + // Touched only from SSE callbacks, which all run on the same strand. + // No lock required. + FDv2ProtocolHandler protocol_handler_; + + std::mutex mutex_; + // All protected by mutex_. + bool started_ = false; + bool closed_ = false; + bool ping_poll_in_flight_ = false; + // From the most recent stream response. + std::optional latest_fdv1_fallback_; + std::optional latest_environment_id_; + data_model::Selector latest_selector_; + std::optional base_url_; + std::shared_ptr sse_client_; + std::optional> pending_promise_; + std::deque result_queue_; + }; + + // Resolved by Close() or on destruction, cancelling any outstanding + // Next() call. + async::Promise close_promise_; + + // Shared with async SSE callbacks. + std::shared_ptr state_; +}; + +} // namespace launchdarkly::client_side::data_sources diff --git a/libs/client-sdk/src/flag_manager/context_index.cpp b/libs/client-sdk/src/flag_manager/context_index.cpp index a8e0fddb9..387d30034 100644 --- a/libs/client-sdk/src/flag_manager/context_index.cpp +++ b/libs/client-sdk/src/flag_manager/context_index.cpp @@ -23,6 +23,16 @@ void ContextIndex::Notice( } } +std::optional> +ContextIndex::GetTimestamp(std::string const& id) const { + for (auto const& entry : index_) { + if (entry.id == id) { + return entry.timestamp; + } + } + return std::nullopt; +} + std::vector ContextIndex::Prune(std::size_t maxContexts) { if (index_.size() <= maxContexts) { return {}; diff --git a/libs/client-sdk/src/flag_manager/context_index.hpp b/libs/client-sdk/src/flag_manager/context_index.hpp index bf96bc235..f418fb5b5 100644 --- a/libs/client-sdk/src/flag_manager/context_index.hpp +++ b/libs/client-sdk/src/flag_manager/context_index.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -21,6 +22,10 @@ namespace launchdarkly::client_side::flag_manager { * 2. timestamp when it was last accessed, to support an LRU * eviction pattern. */ +/** + * Not thread-safe. Instances are short-lived values read out of persistence, + * modified, and written back by a caller holding its own lock. + */ class ContextIndex { public: /** @@ -52,6 +57,14 @@ class ContextIndex { [[nodiscard]] Index const& Entries() const; + /** + * The timestamp recorded for the given id, or nullopt if the id is not in + * the index. + */ + [[nodiscard]] std::optional< + std::chrono::time_point> + GetTimestamp(std::string const& id) const; + /** * Prune the index returning a list of the removed context keys * diff --git a/libs/client-sdk/src/flag_manager/flag_manager.cpp b/libs/client-sdk/src/flag_manager/flag_manager.cpp index 3ac3227bc..bdeba00b8 100644 --- a/libs/client-sdk/src/flag_manager/flag_manager.cpp +++ b/libs/client-sdk/src/flag_manager/flag_manager.cpp @@ -29,6 +29,10 @@ FlagStore const& FlagManager::Store() const { return flag_store_; } +FlagPersistence& FlagManager::Cache() { + return persistence_updater_; +} + void FlagManager::LoadCache(Context const& context) { persistence_updater_.LoadCached(context); } diff --git a/libs/client-sdk/src/flag_manager/flag_manager.hpp b/libs/client-sdk/src/flag_manager/flag_manager.hpp index 1c692ce7b..854264d95 100644 --- a/libs/client-sdk/src/flag_manager/flag_manager.hpp +++ b/libs/client-sdk/src/flag_manager/flag_manager.hpp @@ -8,6 +8,13 @@ namespace launchdarkly::client_side::flag_manager { +/** + * Owns the flag store and the update pipeline that feeds it. + * + * Thread-safe to the extent its parts are: the store, the updater, and the + * persistence layer each carry their own lock. The accessors hand out + * references to those parts and take no lock of their own. + */ class FlagManager { public: FlagManager(std::string const& sdk_key, @@ -18,6 +25,12 @@ class FlagManager { IFlagNotifier& Notifier(); FlagStore const& Store() const; + /** + * The local cache the SDK persists flag data to, and which the FDv2 cache + * initializer reads from. + */ + FlagPersistence& Cache(); + void LoadCache(Context const& context); private: diff --git a/libs/client-sdk/src/flag_manager/flag_persistence.cpp b/libs/client-sdk/src/flag_manager/flag_persistence.cpp index 2aa8143e1..620c4059c 100644 --- a/libs/client-sdk/src/flag_manager/flag_persistence.cpp +++ b/libs/client-sdk/src/flag_manager/flag_persistence.cpp @@ -3,9 +3,10 @@ #include #include +#include +#include #include #include -#include #include @@ -52,17 +53,37 @@ void FlagPersistence::Upsert(Context const& context, StoreCache(PersistenceEncodeKey(context.CanonicalKey())); } -void FlagPersistence::LoadCached(Context const& context) { - if (!persistence_ || !context.Valid()) { +void FlagPersistence::Apply(Context const& context, + FlagChangeSet change_set, + bool from_cache) { + bool const changed_data = + change_set.type != data_model::ChangeSetType::kNone; + sink_.Apply(context, std::move(change_set), from_cache); + if (from_cache) { + // Writing cached data back to the cache would be a no-op, and it was + // never confirmed current by the service. return; } + // Both a payload and a confirmation that nothing changed mean the cache + // is up to date as of now. + RecordFreshness(context); + if (changed_data) { + StoreCache(PersistenceEncodeKey(context.CanonicalKey())); + } +} + +std::optional> +FlagPersistence::ReadCached(Context const& context) { + if (!persistence_ || !context.Valid()) { + return std::nullopt; + } std::lock_guard lock(persistence_mutex_); auto data = persistence_->Read( environment_namespace_, PersistenceEncodeKey(context.CanonicalKey())); if (!data) { - return; + return std::nullopt; } boost::system::error_code error_code; @@ -71,7 +92,7 @@ void FlagPersistence::LoadCached(Context const& context) { LD_LOG(logger_, LogLevel::kError) << "Failed to parse flag data from persistence: " << error_code.message(); - return; + return std::nullopt; } auto res = boost::json::value_to>(parsed); if (!res) { LD_LOG(logger_, LogLevel::kError) - << "Failed to parse flag data from persistence: " - << error_code.message(); - return; + << "Failed to parse flag data from persistence"; + return std::nullopt; } // If the map was null or omitted, treat it like an empty data set. - auto map = - res.value().value_or(std::unordered_map{}); + return res.value().value_or( + std::unordered_map{}); +} - sink_.Init(context, std::move(map)); +void FlagPersistence::LoadCached(Context const& context) { + if (auto data = ReadCached(context)) { + sink_.Init(context, std::move(*data)); + } +} + +// Identifies a context by everything it carries, not just its key. Changing +// an attribute can change how flags evaluate. +static std::string FreshnessId(Context const& context) { + return PersistenceEncodeKey( + boost::json::serialize(boost::json::value_from(context))); +} + +void FlagPersistence::RecordFreshness(Context const& context) { + if (!persistence_ || !context.Valid()) { + return; + } + + std::lock_guard lock(persistence_mutex_); + auto index = ReadIndexAt(freshness_key_); + index.Notice(FreshnessId(context), time_stamper_()); + index.Prune(max_cached_contexts_); + persistence_->Set(environment_namespace_, freshness_key_, + boost::json::serialize(boost::json::value_from(index))); +} + +std::optional> +FlagPersistence::ReadFreshness(Context const& context) { + if (!persistence_ || !context.Valid()) { + return std::nullopt; + } + + std::lock_guard lock(persistence_mutex_); + return ReadIndexAt(freshness_key_).GetTimestamp(FreshnessId(context)); } void FlagPersistence::StoreCache(std::string const& context_id) { @@ -97,7 +151,7 @@ void FlagPersistence::StoreCache(std::string const& context_id) { } std::lock_guard lock(persistence_mutex_); - auto index = GetIndex(); + auto index = ReadIndexAt(index_key_); index.Notice(context_id, time_stamper_()); auto pruned = index.Prune(max_cached_contexts_); for (auto& id : pruned) { @@ -112,11 +166,9 @@ void FlagPersistence::StoreCache(std::string const& context_id) { boost::json::serialize(v)); } -ContextIndex FlagPersistence::GetIndex() { +ContextIndex FlagPersistence::ReadIndexAt(std::string const& key) { if (persistence_) { - std::lock_guard lock(persistence_mutex_); - auto index_data = - persistence_->Read(environment_namespace_, index_key_); + auto index_data = persistence_->Read(environment_namespace_, key); if (index_data) { boost::system::error_code error_code; diff --git a/libs/client-sdk/src/flag_manager/flag_persistence.hpp b/libs/client-sdk/src/flag_manager/flag_persistence.hpp index 5b1f28514..9672a3b3a 100644 --- a/libs/client-sdk/src/flag_manager/flag_persistence.hpp +++ b/libs/client-sdk/src/flag_manager/flag_persistence.hpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include #include "../data_sources/data_source_update_sink.hpp" #include "context_index.hpp" @@ -16,6 +18,15 @@ namespace launchdarkly::client_side::flag_manager { std::string PersistenceEncodeKey(std::string const& input); +/** + * Mirrors data source updates into the persistent store on their way to the + * next sink, and reads them back when a context is loaded. + * + * Thread-safe. The methods that touch the store take persistence_mutex_, + * which makes each stored index read-modify-write atomic. It does not span + * the call to the downstream sink, so an update reaches the store and the + * cache at slightly different times. + */ class FlagPersistence : public IDataSourceUpdateSink { public: using TimeStampsource = @@ -39,25 +50,60 @@ class FlagPersistence : public IDataSourceUpdateSink { std::string key, ItemDescriptor item) override; + void Apply(Context const& context, + FlagChangeSet change_set, + bool from_cache) override; + void LoadCached(Context const& context); + /** + * The flag data stored for the given context, or nullopt when nothing is + * stored for it and when persistence is disabled. An empty map means an + * environment with no flags was stored, which is distinct from nothing + * being stored at all. + */ + [[nodiscard]] std::optional> + ReadCached(Context const& context); + + /** + * When the service last confirmed the flag data for this context was + * current, or nullopt if it never has. + * + * Keyed by the context's whole set of attributes rather than its key, + * because changing an attribute can change how flags evaluate, and the + * answer for the old attributes says nothing about the new ones. + */ + [[nodiscard]] std::optional< + std::chrono::time_point> + ReadFreshness(Context const& context); + private: inline static std::string global_namespace_ = "LaunchDarkly"; inline static std::string index_key_ = "ContextIndex"; + inline static std::string freshness_key_ = "ContextFreshness"; Logger& logger_; std::size_t max_cached_contexts_; IDataSourceUpdateSink& sink_; - std::shared_ptr persistence_; - mutable std::recursive_mutex persistence_mutex_; FlagStore& flag_store_; + // Serializes the read-modify-write of the stored index and flag data, so + // that two contexts being cached at once cannot lose an index entry. + mutable std::recursive_mutex persistence_mutex_; + std::shared_ptr persistence_; + std::string environment_namespace_; TimeStampsource time_stamper_; - ContextIndex GetIndex(); void StoreCache(std::string const& context_id); + + // Records that the service confirmed this context's data is current, as + // of now. + void RecordFreshness(Context const& context); + + // Must be called with persistence_mutex_ held. + ContextIndex ReadIndexAt(std::string const& key); }; } // namespace launchdarkly::client_side::flag_manager diff --git a/libs/client-sdk/src/flag_manager/flag_store.cpp b/libs/client-sdk/src/flag_manager/flag_store.cpp index afec8806c..444aed90b 100644 --- a/libs/client-sdk/src/flag_manager/flag_store.cpp +++ b/libs/client-sdk/src/flag_manager/flag_store.cpp @@ -2,6 +2,8 @@ #include +#include + namespace launchdarkly::client_side::flag_manager { // Shared pointers are used to item descriptors so that they may have a lifetime @@ -9,6 +11,43 @@ namespace launchdarkly::client_side::flag_manager { // accessed, and which it is being used init is called, then we want the // flag being processed to be valid. +namespace { + +// The evaluated value of a descriptor, or null if it has none. +Value FlagValue(ItemDescriptor const& descriptor) { + if (descriptor.item) { + return descriptor.item->Detail().Value(); + } + return {}; +} + +} // namespace + +std::optional ComputeFlagChange( + std::string const& key, + ItemDescriptor const* previous, + ItemDescriptor const* current) { + bool const had_value = previous && previous->item.has_value(); + bool const has_value = current && current->item.has_value(); + + if (has_value) { + auto new_value = FlagValue(*current); + if (had_value) { + auto old_value = FlagValue(*previous); + if (new_value != old_value) { + return FlagValueChangeEvent(key, std::move(new_value), + std::move(old_value)); + } + return std::nullopt; + } + return FlagValueChangeEvent(key, std::move(new_value), Value()); + } + if (had_value) { + return FlagValueChangeEvent(key, FlagValue(*previous)); + } + return std::nullopt; +} + void FlagStore::Init( std::unordered_map const& data) { UpdateData(data); @@ -22,12 +61,88 @@ void FlagStore::UpdateData( this->data_.emplace(item.first, std::make_shared( std::move(item.second))); } + // This data carries no selector, so drop any the store held. + selector_ = data_model::Selector{}; } void FlagStore::Upsert(std::string const& key, ItemDescriptor item) { std::lock_guard lock{data_mutex_}; data_[key] = std::make_shared(std::move(item)); + // This data carries no selector, so drop any the store held. + selector_ = data_model::Selector{}; +} + +std::vector FlagStore::Apply( + FlagChangeSet const& change_set, + bool compute_changes) { + std::lock_guard lock{data_mutex_}; + + std::vector events; + + if (change_set.type == data_model::ChangeSetType::kNone) { + return events; + } + + bool const full = change_set.type == data_model::ChangeSetType::kFull; + + // Snapshot the old data so the events describe the exact transition. + auto previous = std::move(data_); + if (!full) { + data_ = previous; + } else { + data_.clear(); + } + + // The first full data set is what the SDK starts from, not a change to + // it, so it reports nothing. + bool const report = compute_changes && !(full && previous.empty()); + + for (auto const& change : change_set.data) { + if (report) { + auto const existing = previous.find(change.key); + ItemDescriptor const* prev = + existing != previous.end() ? existing->second.get() : nullptr; + if (auto event = + ComputeFlagChange(change.key, prev, &change.item)) { + events.push_back(std::move(*event)); + } + } + + data_[change.key] = std::make_shared(change.item); + } + + // A full changeset is the complete data set, so anything it omits is gone. + if (full && report) { + for (auto const& [key, descriptor] : previous) { + if (data_.count(key) == 0) { + if (auto event = + ComputeFlagChange(key, descriptor.get(), nullptr)) { + events.push_back(std::move(*event)); + } + } + } + } + + if (change_set.selector.value.has_value()) { + selector_ = change_set.selector; + } else { + selector_ = data_model::Selector{}; + } + + return events; +} + +data_model::Selector FlagStore::CurrentSelector() const { + std::lock_guard lock{data_mutex_}; + + return selector_; +} + +void FlagStore::ClearSelector() { + std::lock_guard lock{data_mutex_}; + + selector_ = data_model::Selector{}; } std::shared_ptr FlagStore::Get( diff --git a/libs/client-sdk/src/flag_manager/flag_store.hpp b/libs/client-sdk/src/flag_manager/flag_store.hpp index 112c39b01..53c64cc68 100644 --- a/libs/client-sdk/src/flag_manager/flag_store.hpp +++ b/libs/client-sdk/src/flag_manager/flag_store.hpp @@ -1,21 +1,65 @@ #pragma once #include +#include #include #include +#include #include "../data_sources/data_source_update_sink.hpp" #include "context_index.hpp" +#include +#include #include namespace launchdarkly::client_side::flag_manager { +/** + * Holds the flag data the SDK evaluates against, plus the selector + * identifying the state of that data. + * + * Thread-safe: every method may be called from any thread, and each call is + * atomic, so a reader never observes a partially applied write. + */ class FlagStore { public: void Init(std::unordered_map const& data); void Upsert(std::string const& key, ItemDescriptor item); + /** + * Applies a changeset as a single unit. + * + * The changeset's selector becomes the store's selector. A full or + * partial changeset carrying no selector clears it instead, because the + * resulting data no longer corresponds to a state the service can + * compute deltas against. A "none" changeset leaves the selector alone. + * + * The first full data set the store receives is reported as no changes at + * all, since it is what the SDK starts from rather than a change to it. + * + * @param compute_changes Whether to report the resulting value changes. + * Pass false when nothing is listening for them, to skip the comparison. + * @return The value changes the apply produced, in an unspecified order. + * Empty when compute_changes is false. + */ + std::vector Apply(FlagChangeSet const& change_set, + bool compute_changes); + + /** + * The selector for the data currently held, or an empty selector if that + * data did not come with one. An empty selector means the SDK has no + * verified basis on which to request incremental updates. + */ + [[nodiscard]] data_model::Selector CurrentSelector() const; + + /** + * Forgets the current selector, leaving the flag data in place. Called + * when the evaluation context changes, since a selector describes one + * context's data and is never reused for another. + */ + void ClearSelector(); + /** * Attempts to get a flag by key from the current flags. * @@ -37,8 +81,21 @@ class FlagStore { void UpdateData( std::unordered_map const& data); + // Both protected by data_mutex_. std::unordered_map> data_; + data_model::Selector selector_; + mutable std::mutex data_mutex_; }; +/** + * Computes the value-change event for one flag moving from previous to + * current. Either pointer may be null when the flag is absent. Returns + * nullopt when the evaluated value did not change. + */ +std::optional ComputeFlagChange( + std::string const& key, + ItemDescriptor const* previous, + ItemDescriptor const* current); + } // namespace launchdarkly::client_side::flag_manager diff --git a/libs/client-sdk/src/flag_manager/flag_updater.cpp b/libs/client-sdk/src/flag_manager/flag_updater.cpp index e1632c9c8..c82de777b 100644 --- a/libs/client-sdk/src/flag_manager/flag_updater.cpp +++ b/libs/client-sdk/src/flag_manager/flag_updater.cpp @@ -8,20 +8,10 @@ namespace launchdarkly::client_side::flag_manager { FlagUpdater::FlagUpdater(FlagStore& flag_store) : flag_store_(flag_store) {} -Value GetValue(ItemDescriptor& descriptor) { - if (descriptor.item) { - // `flag->` unwraps the first optional we know is present. - // The second `value()` is not an optional. - return descriptor.item->Detail().Value(); - } - return {}; -} - void FlagUpdater::Init(Context const& context, std::unordered_map data) { std::lock_guard lock{signal_mutex_}; - // Calculate what flags changed. std::list change_events; auto old_flags = flag_store_.GetAll(); @@ -30,36 +20,19 @@ void FlagUpdater::Init(Context const& context, if (!old_flags.empty() && HasListeners()) { for (auto& new_pair : data) { auto existing = old_flags.find(new_pair.first); - if (existing != old_flags.end()) { - // The flag changed. - auto& evaluation_result = new_pair.second.item; - if (evaluation_result) { - auto new_value = GetValue(new_pair.second); - auto old_value = GetValue(*existing->second); - if (new_value != old_value) { - // Updated. - change_events.emplace_back(new_pair.first, - GetValue(new_pair.second), - GetValue(*existing->second)); - } - } else { - // Deleted. - change_events.emplace_back(existing->first, - GetValue(*existing->second)); - } - - } else { - // It is a new flag. - change_events.emplace_back(new_pair.first, - GetValue(new_pair.second), Value()); + ItemDescriptor const* previous = + existing != old_flags.end() ? existing->second.get() : nullptr; + if (auto event = ComputeFlagChange(new_pair.first, previous, + &new_pair.second)) { + change_events.push_back(std::move(*event)); } } for (auto& old_pair : old_flags) { - auto still_exists = data.count(old_pair.first) != 0; - if (!still_exists) { - // Was in the old data, but not the new data, so it was deleted. - change_events.emplace_back(old_pair.first, - GetValue(*old_pair.second)); + if (data.count(old_pair.first) == 0) { + if (auto event = ComputeFlagChange( + old_pair.first, old_pair.second.get(), nullptr)) { + change_events.push_back(std::move(*event)); + } } } } @@ -67,10 +40,21 @@ void FlagUpdater::Init(Context const& context, flag_store_.Init(data); for (auto& event : change_events) { - // Send the event. DispatchEvent(std::move(event)); } } +void FlagUpdater::Apply(Context const& context, + FlagChangeSet change_set, + bool /* from_cache */) { + std::lock_guard lock{signal_mutex_}; + + auto events = flag_store_.Apply(change_set, HasListeners()); + + for (auto& event : events) { + DispatchEvent(std::move(event)); + } +} + void FlagUpdater::DispatchEvent(FlagValueChangeEvent event) { auto handler = signals_.find(event.FlagName()); if (handler != signals_.end()) { @@ -97,20 +81,8 @@ void FlagUpdater::Upsert(Context const& context, flag_store_.Upsert(key, descriptor); if (HasListeners()) { - // Existed and updated. - if (existing && descriptor.item) { - DispatchEvent(FlagValueChangeEvent(key, GetValue(descriptor), - GetValue(*existing))); - } else if (descriptor.item) { - DispatchEvent(FlagValueChangeEvent( - key, descriptor.item.value().Detail().Value(), Value())); - // new flag - } else if (existing && existing->item.has_value()) { - // Existed and deleted. - DispatchEvent(FlagValueChangeEvent(key, GetValue(*existing))); - } else { - // Was deleted and is still deleted. - // Do nothing. + if (auto event = ComputeFlagChange(key, existing.get(), &descriptor)) { + DispatchEvent(std::move(*event)); } } } diff --git a/libs/client-sdk/src/flag_manager/flag_updater.hpp b/libs/client-sdk/src/flag_manager/flag_updater.hpp index 1e5436931..0b082914f 100644 --- a/libs/client-sdk/src/flag_manager/flag_updater.hpp +++ b/libs/client-sdk/src/flag_manager/flag_updater.hpp @@ -15,6 +15,16 @@ namespace launchdarkly::client_side::flag_manager { +/** + * Applies data source updates to the store and dispatches the resulting + * change events to registered listeners. + * + * Thread-safe. Every method takes signal_mutex_, so an update arriving on a + * data source's thread cannot interleave with a listener being registered or + * removed. Listener callbacks run on the thread that delivered the update, + * while that mutex is held, so a callback must not register listeners of its + * own. + */ class FlagUpdater : public IDataSourceUpdateSink, public IFlagNotifier { public: FlagUpdater(FlagStore& flag_store); @@ -23,6 +33,9 @@ class FlagUpdater : public IDataSourceUpdateSink, public IFlagNotifier { void Upsert(Context const& context, std::string key, ItemDescriptor item) override; + void Apply(Context const& context, + FlagChangeSet change_set, + bool from_cache) override; /** * Listen for changes for the specific flag. diff --git a/libs/client-sdk/tests/CMakeLists.txt b/libs/client-sdk/tests/CMakeLists.txt index 5f53df407..0f3f2c8a2 100644 --- a/libs/client-sdk/tests/CMakeLists.txt +++ b/libs/client-sdk/tests/CMakeLists.txt @@ -16,6 +16,6 @@ endif () add_executable(gtest_${LIBNAME} ${tests}) -target_link_libraries(gtest_${LIBNAME} launchdarkly::client launchdarkly::internal GTest::gtest_main) +target_link_libraries(gtest_${LIBNAME} launchdarkly::client launchdarkly::internal launchdarkly::sse GTest::gtest_main) gtest_discover_tests(gtest_${LIBNAME}) diff --git a/libs/client-sdk/tests/data_source_event_handler_test.cpp b/libs/client-sdk/tests/data_source_event_handler_test.cpp index dc57830be..740c16da3 100644 --- a/libs/client-sdk/tests/data_source_event_handler_test.cpp +++ b/libs/client-sdk/tests/data_source_event_handler_test.cpp @@ -25,10 +25,17 @@ class TestHandler : public IDataSourceUpdateSink { upsert_data_.emplace_back(key, data); count_ += 1; } + void Apply(Context const& context, + FlagChangeSet change_set, + bool from_cache) override { + apply_data_.push_back(std::move(change_set)); + count_ += 1; + } uint64_t count_ = 0; std::vector> init_data_; std::vector> upsert_data_; + std::vector apply_data_; }; TEST(StreamingDataHandlerTests, HandlesPutMessage) { diff --git a/libs/client-sdk/tests/fdv2_cache_initializer_test.cpp b/libs/client-sdk/tests/fdv2_cache_initializer_test.cpp new file mode 100644 index 000000000..15baa00e9 --- /dev/null +++ b/libs/client-sdk/tests/fdv2_cache_initializer_test.cpp @@ -0,0 +1,147 @@ +#include + +#include +#include + +#include +#include + +#include +#include +#include +#include + +using launchdarkly::ContextBuilder; +using launchdarkly::Value; +using launchdarkly::client_side::flag_manager::FlagManager; +using launchdarkly::client_side::flag_manager::PersistenceEncodeKey; +using launchdarkly::data_model::ChangeSetType; + +using namespace launchdarkly::client_side::data_sources; + +namespace { + +class TestPersistence : public IPersistence { + public: + using StoreType = + std::map>>; + + explicit TestPersistence(StoreType store) : store_(std::move(store)) {} + + void Set(std::string storageNamespace, + std::string key, + std::string data) noexcept override { + store_[storageNamespace][key] = data; + } + + void Remove(std::string storageNamespace, + std::string key) noexcept override { + store_[storageNamespace].erase(key); + } + + std::optional Read(std::string storageNamespace, + std::string key) noexcept override { + return store_[storageNamespace][key]; + } + + StoreType store_; +}; + +// The environment namespace and context id the client derives for SDK key +// "the-key" and context user:user-key. +char const* const kEnvironment = + "LaunchDarkly_rUTcjlHPv6Vegd27YmtGYkEGkEUGaEbn5M0JYTFQUpA="; +char const* const kContextId = "CEXjZY7cHJG_ydFy7q4-YEFwVrG3_pkJwA4FAjrbfx0="; + +} // namespace + +TEST(FDv2CacheInitializerTest, CacheHitProducesAFullDataSet) { + auto context = ContextBuilder().Kind("user", "user-key").Build(); + auto logger = launchdarkly::logging::NullLogger(); + auto persistence = + std::make_shared(TestPersistence::StoreType{ + {kEnvironment, + {{kContextId, R"({"flagA":{"version":1,"value":"test"}})"}}}}); + FlagManager flag_manager("the-key", logger, 5, persistence); + + FDv2CacheInitializer initializer(&flag_manager.Cache(), context, logger); + auto future = initializer.Run(); + ASSERT_TRUE(future.IsFinished()); + auto result = future.GetResult(); + + auto* change_set = std::get_if(&result->value); + ASSERT_NE(nullptr, change_set); + EXPECT_EQ(ChangeSetType::kFull, change_set->change_set.type); + ASSERT_EQ(1u, change_set->change_set.data.size()); + EXPECT_EQ("flagA", change_set->change_set.data[0].key); + EXPECT_EQ(Value("test"), + change_set->change_set.data[0].item.item->Detail().Value()); +} + +// Asking the service for a delta against unverified cached data could corrupt +// the store silently, so the cache never supplies a basis. +TEST(FDv2CacheInitializerTest, CachedDataCarriesNoSelector) { + auto context = ContextBuilder().Kind("user", "user-key").Build(); + auto logger = launchdarkly::logging::NullLogger(); + auto persistence = + std::make_shared(TestPersistence::StoreType{ + {kEnvironment, + {{kContextId, R"({"flagA":{"version":1,"value":"test"}})"}}}}); + FlagManager flag_manager("the-key", logger, 5, persistence); + + FDv2CacheInitializer initializer(&flag_manager.Cache(), context, logger); + auto result = initializer.Run().GetResult(); + + auto* change_set = std::get_if(&result->value); + ASSERT_NE(nullptr, change_set); + EXPECT_FALSE(change_set->change_set.selector.value.has_value()); +} + +// A miss leaves the data set unchanged and lets the chain proceed, rather than +// reporting an error. +TEST(FDv2CacheInitializerTest, CacheMissProducesANoneIntent) { + auto context = ContextBuilder().Kind("user", "unknown").Build(); + auto logger = launchdarkly::logging::NullLogger(); + auto persistence = + std::make_shared(TestPersistence::StoreType()); + FlagManager flag_manager("the-key", logger, 5, persistence); + + FDv2CacheInitializer initializer(&flag_manager.Cache(), context, logger); + auto future = initializer.Run(); + ASSERT_TRUE(future.IsFinished()); + auto result = future.GetResult(); + + auto* change_set = std::get_if(&result->value); + ASSERT_NE(nullptr, change_set); + EXPECT_EQ(ChangeSetType::kNone, change_set->change_set.type); + EXPECT_TRUE(change_set->change_set.data.empty()); +} + +TEST(FDv2CacheInitializerTest, NoPersistenceConfiguredProducesANoneIntent) { + auto context = ContextBuilder().Kind("user", "user-key").Build(); + auto logger = launchdarkly::logging::NullLogger(); + FlagManager flag_manager("the-key", logger, 5, nullptr); + + FDv2CacheInitializer initializer(&flag_manager.Cache(), context, logger); + auto future = initializer.Run(); + ASSERT_TRUE(future.IsFinished()); + auto result = future.GetResult(); + + auto* change_set = std::get_if(&result->value); + ASSERT_NE(nullptr, change_set); + EXPECT_EQ(ChangeSetType::kNone, change_set->change_set.type); +} + +// The orchestrator needs to tell cache initializers apart from network ones, +// so that a miss with nothing else configured still starts the SDK. +TEST(FDv2CacheInitializerTest, FactoryIdentifiesItselfAsReadingTheCache) { + auto context = ContextBuilder().Kind("user", "user-key").Build(); + auto logger = launchdarkly::logging::NullLogger(); + FlagManager flag_manager("the-key", logger, 5, nullptr); + + FDv2CacheInitializerFactory factory(&flag_manager.Cache(), context, logger); + + EXPECT_TRUE(factory.IsFromCache()); + EXPECT_NE(nullptr, factory.Build()); +} diff --git a/libs/client-sdk/tests/fdv2_changeset_translation_test.cpp b/libs/client-sdk/tests/fdv2_changeset_translation_test.cpp new file mode 100644 index 000000000..85f41faed --- /dev/null +++ b/libs/client-sdk/tests/fdv2_changeset_translation_test.cpp @@ -0,0 +1,184 @@ +#include + +#include + +#include +#include + +#include + +using namespace launchdarkly; +using namespace launchdarkly::data_model; +using namespace launchdarkly::client_side; +using namespace launchdarkly::client_side::data_sources; + +// A flag-eval object on the wire. It has a flagVersion but no version. The +// enclosing put-object envelope carries the version instead. +static char const* const kFlagEvalJson = + R"({"value":"a","variation":1,"flagVersion":5,"trackEvents":true})"; + +// Object with its own version, which is overridden by the envelope's. +static char const* const kFlagEvalJsonWithVersion = + R"({"value":"a","variation":1,"version":99,"trackEvents":true})"; + +static Logger MakeNullLogger() { + struct NullBackend : ILogBackend { + bool Enabled(LogLevel) noexcept override { return false; } + void Write(LogLevel, std::string) noexcept override {} + }; + return Logger{std::make_shared()}; +} + +static FDv2Change Put(std::string kind, + std::string key, + std::uint64_t version, + char const* json) { + return FDv2Change{FDv2Change::ChangeType::kPut, std::move(kind), + std::move(key), version, boost::json::parse(json)}; +} + +static FDv2Change Delete(std::string kind, + std::string key, + std::uint64_t version) { + return FDv2Change{FDv2Change::ChangeType::kDelete, + std::move(kind), + std::move(key), + version, + {}}; +} + +TEST(ClientFDv2ChangeSetTranslationTest, NoneChangeSetCarriesNoData) { + auto logger = MakeNullLogger(); + + FDv2ChangeSet raw{ChangeSetType::kNone, {}, Selector{}}; + auto result = TranslateChangeSet(raw, logger); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->type, ChangeSetType::kNone); + EXPECT_TRUE(result->data.empty()); +} + +TEST(ClientFDv2ChangeSetTranslationTest, TypeAndSelectorCarryThrough) { + auto logger = MakeNullLogger(); + + FDv2ChangeSet raw{ + ChangeSetType::kPartial, {}, Selector{Selector::State{7, "state-7"}}}; + auto result = TranslateChangeSet(raw, logger); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->type, ChangeSetType::kPartial); + ASSERT_TRUE(result->selector.value.has_value()); + EXPECT_EQ(result->selector.value->version, 7); + EXPECT_EQ(result->selector.value->state, "state-7"); +} + +TEST(ClientFDv2ChangeSetTranslationTest, PutFlagEvalProducesEvaluationResult) { + auto logger = MakeNullLogger(); + + FDv2ChangeSet raw{ChangeSetType::kFull, + {Put("flag-eval", "my-flag", 12, kFlagEvalJson)}, + Selector{}}; + auto result = TranslateChangeSet(raw, logger); + + ASSERT_TRUE(result.has_value()); + ASSERT_EQ(result->data.size(), 1u); + EXPECT_EQ(result->data[0].key, "my-flag"); + ASSERT_TRUE(result->data[0].item.item.has_value()); + EXPECT_EQ(result->data[0].item.item->Detail().Value(), Value("a")); + EXPECT_EQ(result->data[0].item.item->Detail().VariationIndex(), 1); + EXPECT_TRUE(result->data[0].item.item->TrackEvents()); +} + +TEST(ClientFDv2ChangeSetTranslationTest, PutTakesTheEnvelopeVersion) { + auto logger = MakeNullLogger(); + + FDv2ChangeSet raw{ChangeSetType::kFull, + {Put("flag-eval", "my-flag", 12, kFlagEvalJson)}, + Selector{}}; + auto result = TranslateChangeSet(raw, logger); + + ASSERT_TRUE(result.has_value()); + ASSERT_EQ(result->data.size(), 1u); + EXPECT_EQ(result->data[0].item.version, 12u); + ASSERT_TRUE(result->data[0].item.item.has_value()); + EXPECT_EQ(result->data[0].item.item->Version(), 12u); +} + +TEST(ClientFDv2ChangeSetTranslationTest, EnvelopeVersionOverridesTheObjects) { + auto logger = MakeNullLogger(); + + FDv2ChangeSet raw{ + ChangeSetType::kFull, + {Put("flag-eval", "my-flag", 12, kFlagEvalJsonWithVersion)}, + Selector{}}; + auto result = TranslateChangeSet(raw, logger); + + ASSERT_TRUE(result.has_value()); + ASSERT_EQ(result->data.size(), 1u); + EXPECT_EQ(result->data[0].item.version, 12u); + ASSERT_TRUE(result->data[0].item.item.has_value()); + EXPECT_EQ(result->data[0].item.item->Version(), 12u); +} + +TEST(ClientFDv2ChangeSetTranslationTest, PutOfUnknownKindIsSkipped) { + auto logger = MakeNullLogger(); + + FDv2ChangeSet raw{ChangeSetType::kFull, + {Put("segment", "my-seg", 1, R"({"key":"my-seg"})"), + Put("flag-eval", "my-flag", 12, kFlagEvalJson)}, + Selector{}}; + auto result = TranslateChangeSet(raw, logger); + + ASSERT_TRUE(result.has_value()); + ASSERT_EQ(result->data.size(), 1u); + EXPECT_EQ(result->data[0].key, "my-flag"); +} + +TEST(ClientFDv2ChangeSetTranslationTest, PutOfNullObjectIsSkipped) { + auto logger = MakeNullLogger(); + + FDv2ChangeSet raw{ChangeSetType::kFull, + {Put("flag-eval", "my-flag", 12, "null")}, + Selector{}}; + auto result = TranslateChangeSet(raw, logger); + + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE(result->data.empty()); +} + +TEST(ClientFDv2ChangeSetTranslationTest, PutThatFailsToDeserializeAbandonsAll) { + auto logger = MakeNullLogger(); + + FDv2ChangeSet raw{ChangeSetType::kFull, + {Put("flag-eval", "good", 12, kFlagEvalJson), + Put("flag-eval", "bad", 13, R"(["not-an-object"])")}, + Selector{}}; + auto result = TranslateChangeSet(raw, logger); + + EXPECT_FALSE(result.has_value()); +} + +TEST(ClientFDv2ChangeSetTranslationTest, DeleteOfFlagEvalKindTombstones) { + auto logger = MakeNullLogger(); + + FDv2ChangeSet raw{ + ChangeSetType::kPartial, {Delete("flag-eval", "gone", 42)}, Selector{}}; + auto result = TranslateChangeSet(raw, logger); + + ASSERT_TRUE(result.has_value()); + ASSERT_EQ(result->data.size(), 1u); + EXPECT_EQ(result->data[0].key, "gone"); + EXPECT_EQ(result->data[0].item.version, 42u); + EXPECT_FALSE(result->data[0].item.item.has_value()); +} + +TEST(ClientFDv2ChangeSetTranslationTest, DeleteOfUnknownKindIsSkipped) { + auto logger = MakeNullLogger(); + + FDv2ChangeSet raw{ + ChangeSetType::kPartial, {Delete("segment", "gone", 42)}, Selector{}}; + auto result = TranslateChangeSet(raw, logger); + + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE(result->data.empty()); +} diff --git a/libs/client-sdk/tests/fdv2_data_source_test.cpp b/libs/client-sdk/tests/fdv2_data_source_test.cpp new file mode 100644 index 000000000..bf365618d --- /dev/null +++ b/libs/client-sdk/tests/fdv2_data_source_test.cpp @@ -0,0 +1,803 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +using namespace launchdarkly; +using namespace launchdarkly::client_side; +using namespace launchdarkly::client_side::data_sources; +using namespace std::chrono_literals; + +namespace { + +Logger MakeNullLogger() { + struct NullBackend : ILogBackend { + bool Enabled(LogLevel) noexcept override { return false; } + void Write(LogLevel, std::string) noexcept override {} + }; + return Logger{std::make_shared()}; +} + +// Initializer that resolves Run() with a single pre-set result. +class MockInitializer : public IFDv2Initializer { + public: + explicit MockInitializer(FDv2SourceResult result, + bool* closed_flag = nullptr) + : result_(std::move(result)), closed_flag_(closed_flag) {} + + async::Future Run() override { + return async::MakeFuture(std::move(result_)); + } + + void Close() override { + if (closed_flag_) { + *closed_flag_ = true; + } + } + + std::string const& Identity() const override { + static std::string const id = "mock initializer"; + return id; + } + + private: + FDv2SourceResult result_; + bool* closed_flag_; +}; + +// Synchronizer that resolves successive Next() calls from a queue of results. +// Once the queue is exhausted it returns Shutdown to end orchestration, unless +// stall_after_results is set, in which case the next Future never resolves. +class MockSynchronizer : public IFDv2Synchronizer { + public: + MockSynchronizer(std::vector results, + bool* closed_flag = nullptr, + std::vector* next_calls = nullptr, + bool stall_after_results = false) + : results_(std::move(results)), + closed_flag_(closed_flag), + next_calls_(next_calls), + stall_after_results_(stall_after_results) {} + + async::Future Next( + data_model::Selector selector) override { + if (next_calls_) { + next_calls_->push_back(selector); + } + if (call_index_ < results_.size()) { + return async::MakeFuture(std::move(results_[call_index_++])); + } + if (stall_after_results_) { + return stall_promise_.GetFuture(); + } + return async::MakeFuture( + FDv2SourceResult{FDv2SourceResult::Shutdown{}}); + } + + void Close() override { + stall_promise_.Resolve(FDv2SourceResult{FDv2SourceResult::Shutdown{}}); + if (closed_flag_) { + *closed_flag_ = true; + } + } + + std::string const& Identity() const override { + static std::string const id = "mock synchronizer"; + return id; + } + + private: + std::vector results_; + std::size_t call_index_ = 0; + bool* closed_flag_; + std::vector* next_calls_; + bool stall_after_results_; + async::Promise stall_promise_; +}; + +// Returns a pre-supplied source on its first Build() call. +class OneShotInitializerFactory : public IFDv2InitializerFactory { + public: + explicit OneShotInitializerFactory(std::unique_ptr source, + bool from_cache = false) + : source_(std::move(source)), from_cache_(from_cache) {} + + std::unique_ptr Build() override { + ++build_count_; + return std::move(source_); + } + + [[nodiscard]] bool IsFromCache() const override { return from_cache_; } + + int build_count_ = 0; + + private: + std::unique_ptr source_; + bool from_cache_; +}; + +class OneShotSynchronizerFactory : public IFDv2SynchronizerFactory { + public: + explicit OneShotSynchronizerFactory( + std::unique_ptr source) + : source_(std::move(source)) {} + + std::unique_ptr Build() override { + ++build_count_; + return std::move(source_); + } + + int build_count_ = 0; + + private: + std::unique_ptr source_; +}; + +// Returns each pre-supplied source in order on successive Build() calls, so +// that a factory reused by recovery can hand out a fresh source. +class MultiShotSynchronizerFactory : public IFDv2SynchronizerFactory { + public: + explicit MultiShotSynchronizerFactory( + std::vector> sources) + : sources_(std::move(sources)) {} + + std::unique_ptr Build() override { + ++build_count_; + if (build_count_ <= static_cast(sources_.size())) { + return std::move(sources_[build_count_ - 1]); + } + return nullptr; + } + + int build_count_ = 0; + + private: + std::vector> sources_; +}; + +// Initializer whose Run() stays pending until Deliver() resolves it, so +// orchestration can be examined in flight. +class StalledInitializer : public IFDv2Initializer { + public: + explicit StalledInitializer(bool* closed_flag = nullptr) + : closed_flag_(closed_flag) {} + + async::Future Run() override { + return promise_.GetFuture(); + } + + // Resolves the pending Run() future with the given result. + void Deliver(FDv2SourceResult result) { + promise_.Resolve(std::move(result)); + } + + void Close() override { + if (closed_flag_) { + *closed_flag_ = true; + } + } + + std::string const& Identity() const override { + static std::string const id = "stalled initializer"; + return id; + } + + private: + async::Promise promise_; + bool* closed_flag_; +}; + +data_model::Selector MakeSelector(std::int64_t version, std::string state) { + return data_model::Selector{ + data_model::Selector::State{version, std::move(state)}}; +} + +ItemDescriptor MakeFlag(std::uint64_t version, Value value) { + return ItemDescriptor{ + EvaluationResult{version, std::nullopt, false, false, std::nullopt, + EvaluationDetailInternal{std::move(value), + std::nullopt, std::nullopt}}}; +} + +FDv2SourceResult MakeChangeSetResult(data_model::ChangeSetType type, + FlagChangeSetData data, + data_model::Selector selector) { + return FDv2SourceResult{FDv2SourceResult::ChangeSet{ + FlagChangeSet{type, std::move(data), std::move(selector)}}}; +} + +FDv2SourceResult MakeErrorResult(FDv2SourceResult::Value value) { + return FDv2SourceResult{std::move(value)}; +} + +FDv2SourceResult::ErrorInfo MakeError(std::string message) { + return FDv2SourceResult::ErrorInfo{ + FDv2SourceResult::ErrorInfo::ErrorKind::kNetworkError, 0, + std::move(message), std::chrono::system_clock::now()}; +} + +// Records the from_cache flag of each apply before passing it along, so tests +// can assert how the data source classified its sources. +class RecordingSink : public IDataSourceUpdateSink { + public: + RecordingSink(IDataSourceUpdateSink* inner, std::vector* applies) + : inner_(inner), applies_(applies) {} + + void Init(Context const& context, + std::unordered_map data) override { + inner_->Init(context, std::move(data)); + } + + void Upsert(Context const& context, + std::string key, + ItemDescriptor item) override { + inner_->Upsert(context, std::move(key), std::move(item)); + } + + void Apply(Context const& context, + FlagChangeSet change_set, + bool from_cache) override { + applies_->push_back(from_cache); + inner_->Apply(context, std::move(change_set), from_cache); + } + + private: + IDataSourceUpdateSink* const inner_; + std::vector* const applies_; +}; + +// Owns everything a data source needs to run against a real flag store. +class Harness { + public: + Harness() + : flag_manager_("sdk-key", logger_, 5, nullptr), + sink_(&flag_manager_.Updater(), &applies_) {} + + std::shared_ptr MakeDataSource( + std::vector> initializers, + std::vector> synchronizers, + std::unique_ptr fallback = nullptr, + std::unique_ptr recovery = nullptr) { + return std::make_shared( + std::move(initializers), std::move(synchronizers), + std::move(fallback), std::move(recovery), ioc_.get_executor(), + ContextBuilder().Kind("user", "user-key").Build(), &sink_, + &flag_manager_.Store(), &status_manager_, logger_); + } + + boost::asio::io_context& Context() { return ioc_; } + DataSourceStatusManager& StatusManager() { return status_manager_; } + flag_manager::FlagStore const& Store() { return flag_manager_.Store(); } + + DataSourceStatus::DataSourceState State() { + return status_manager_.Status().State(); + } + + std::vector const& Applies() const { return applies_; } + + private: + Logger logger_ = MakeNullLogger(); + boost::asio::io_context ioc_; + DataSourceStatusManager status_manager_; + flag_manager::FlagManager flag_manager_; + std::vector applies_; + RecordingSink sink_; +}; + +} // namespace + +// ============================================================================ +// Lifecycle +// ============================================================================ + +TEST(ClientFDv2DataSourceTest, NoSourcesConfiguredIsImmediatelyValid) { + Harness h; + auto source = h.MakeDataSource({}, {}); + + source->Start(); + + EXPECT_EQ(DataSourceStatus::DataSourceState::kValid, h.State()); +} + +// The client drops a data source as soon as its replacement starts, so a +// status transition from the old one would land on top of the new one's. +TEST(ClientFDv2DataSourceTest, NoStatusIsReportedAfterShutdown) { + Harness h; + + std::vector> initializers; + initializers.push_back(std::make_unique( + std::make_unique(MakeChangeSetResult( + data_model::ChangeSetType::kNone, {}, data_model::Selector{})))); + + auto source = h.MakeDataSource(std::move(initializers), {}); + source->Start(); + + bool completed = false; + source->ShutdownAsync([&completed]() { completed = true; }); + h.StatusManager().SetState(DataSourceStatus::DataSourceState::kValid); + + // Whatever the abandoned orchestration had queued must not overwrite the + // state the caller sees after the shutdown. + h.Context().poll(); + + EXPECT_TRUE(completed); + EXPECT_EQ(DataSourceStatus::DataSourceState::kValid, h.State()); +} + +TEST(ClientFDv2DataSourceTest, ShutdownClosesTheActiveInitializer) { + Harness h; + bool closed = false; + + std::vector> initializers; + initializers.push_back(std::make_unique( + std::make_unique(&closed))); + + auto source = h.MakeDataSource(std::move(initializers), {}); + source->Start(); + h.Context().run(); + + bool completed = false; + source->ShutdownAsync([&completed] { completed = true; }); + h.Context().restart(); + h.Context().run(); + + EXPECT_TRUE(closed); + EXPECT_TRUE(completed); +} + +// A result delivered after shutdown must not reach the store or status, so an +// identify restart cannot apply the old context over the new one. +TEST(ClientFDv2DataSourceTest, ResultAfterShutdownIsNotApplied) { + Harness h; + + auto stalled = std::make_unique(); + auto* stalled_ptr = stalled.get(); + std::vector> initializers; + initializers.push_back( + std::make_unique(std::move(stalled))); + + auto source = h.MakeDataSource(std::move(initializers), {}); + source->Start(); + h.Context().run(); + + source->ShutdownAsync([] {}); + + // The initializer delivers a full basis after shutdown. + stalled_ptr->Deliver( + MakeChangeSetResult(data_model::ChangeSetType::kFull, + {FlagChange{"flagA", MakeFlag(1, Value("a"))}}, + MakeSelector(1, "state-1"))); + h.Context().restart(); + h.Context().run(); + + EXPECT_TRUE(h.Applies().empty()); + EXPECT_FALSE(h.Store().Get("flagA")); + EXPECT_NE(DataSourceStatus::DataSourceState::kValid, h.State()); +} + +// ============================================================================ +// Initializer phase +// ============================================================================ + +TEST(ClientFDv2DataSourceTest, InitializerWithABasisAppliesAndBecomesValid) { + Harness h; + + std::vector> initializers; + initializers.push_back(std::make_unique( + std::make_unique( + MakeChangeSetResult(data_model::ChangeSetType::kFull, + {FlagChange{"flagA", MakeFlag(1, Value("a"))}}, + MakeSelector(1, "state-1"))))); + + auto source = h.MakeDataSource(std::move(initializers), {}); + source->Start(); + h.Context().run(); + + EXPECT_EQ(DataSourceStatus::DataSourceState::kValid, h.State()); + ASSERT_TRUE(h.Store().Get("flagA")); + EXPECT_EQ(Value("a"), h.Store().Get("flagA")->item->Detail().Value()); + ASSERT_TRUE(h.Store().CurrentSelector().value.has_value()); + EXPECT_EQ("state-1", h.Store().CurrentSelector().value->state); +} + +// An initializer that supplies data without a selector has not established a +// basis, so the chain keeps going to find one. +TEST(ClientFDv2DataSourceTest, DataWithoutASelectorContinuesTheChain) { + Harness h; + + std::vector> initializers; + initializers.push_back(std::make_unique( + std::make_unique(MakeChangeSetResult( + data_model::ChangeSetType::kFull, + {FlagChange{"cached", MakeFlag(1, Value("from-cache"))}}, + data_model::Selector{})))); + auto second = std::make_unique( + std::make_unique(MakeChangeSetResult( + data_model::ChangeSetType::kFull, + {FlagChange{"live", MakeFlag(1, Value("from-network"))}}, + MakeSelector(1, "state-1")))); + auto* second_ptr = second.get(); + initializers.push_back(std::move(second)); + + auto source = h.MakeDataSource(std::move(initializers), {}); + source->Start(); + h.Context().run(); + + EXPECT_EQ(1, second_ptr->build_count_); + EXPECT_FALSE(h.Store().Get("cached")); + ASSERT_TRUE(h.Store().Get("live")); +} + +TEST(ClientFDv2DataSourceTest, FailedInitializerAdvancesToTheNext) { + Harness h; + + std::vector> initializers; + initializers.push_back(std::make_unique( + std::make_unique(MakeErrorResult( + FDv2SourceResult::Interrupted{MakeError("boom")})))); + initializers.push_back(std::make_unique( + std::make_unique( + MakeChangeSetResult(data_model::ChangeSetType::kFull, + {FlagChange{"flagA", MakeFlag(1, Value("a"))}}, + MakeSelector(1, "state-1"))))); + + auto source = h.MakeDataSource(std::move(initializers), {}); + source->Start(); + h.Context().run(); + + EXPECT_EQ(DataSourceStatus::DataSourceState::kValid, h.State()); + EXPECT_TRUE(h.Store().Get("flagA")); +} + +TEST(ClientFDv2DataSourceTest, ExhaustedInitializersWithNoDataShutDown) { + Harness h; + + std::vector> initializers; + initializers.push_back(std::make_unique( + std::make_unique(MakeErrorResult( + FDv2SourceResult::TerminalError{MakeError("boom")})))); + + auto source = h.MakeDataSource(std::move(initializers), {}); + source->Start(); + h.Context().run(); + + EXPECT_EQ(DataSourceStatus::DataSourceState::kShutdown, h.State()); +} + +// Under FDv1 the client loaded the cache in its constructor, so cached flags +// were evaluable immediately. They still are. +TEST(ClientFDv2DataSourceTest, CachedDataIsAppliedBeforeStartReturns) { + Harness h; + + std::vector> initializers; + initializers.push_back(std::make_unique( + std::make_unique(MakeChangeSetResult( + data_model::ChangeSetType::kFull, + {FlagChange{"flagA", MakeFlag(1, Value("cached"))}}, + data_model::Selector{})), + /* from_cache= */ true)); + + auto source = h.MakeDataSource(std::move(initializers), {}); + source->Start(); + + // The executor has not run, so only the inline cache pass can have + // applied anything. + auto const flag = h.Store().Get("flagA"); + ASSERT_TRUE(flag); + EXPECT_EQ(Value("cached"), flag->item->Detail().Value()); + EXPECT_EQ(DataSourceStatus::DataSourceState::kValid, h.State()); +} + +// In offline mode the cache is the only thing that could ever supply data, +// so a miss means zero flags rather than a failure to start. +TEST(ClientFDv2DataSourceTest, CacheOnlyModeIsValidEvenOnAMiss) { + Harness h; + + std::vector> initializers; + initializers.push_back(std::make_unique( + std::make_unique(MakeChangeSetResult( + data_model::ChangeSetType::kNone, {}, data_model::Selector{})), + /* from_cache= */ true)); + + auto source = h.MakeDataSource(std::move(initializers), {}); + source->Start(); + h.Context().run(); + + EXPECT_EQ(DataSourceStatus::DataSourceState::kValid, h.State()); + EXPECT_TRUE(h.Store().GetAll().empty()); +} + +// A non-cache initializer returning "none" must not make initialization +// succeed when nothing else can supply data. +TEST(ClientFDv2DataSourceTest, NetworkOnlyNoneResultDoesNotCountAsSuccess) { + Harness h; + + std::vector> initializers; + initializers.push_back(std::make_unique( + std::make_unique(MakeChangeSetResult( + data_model::ChangeSetType::kNone, {}, data_model::Selector{})))); + + auto source = h.MakeDataSource(std::move(initializers), {}); + source->Start(); + h.Context().run(); + + EXPECT_EQ(DataSourceStatus::DataSourceState::kShutdown, h.State()); +} + +// ============================================================================ +// Synchronizer phase +// ============================================================================ + +TEST(ClientFDv2DataSourceTest, SynchronizerChangeSetsAreApplied) { + Harness h; + + std::vector results; + results.push_back( + MakeChangeSetResult(data_model::ChangeSetType::kFull, + {FlagChange{"flagA", MakeFlag(1, Value("a"))}}, + MakeSelector(1, "state-1"))); + results.push_back( + MakeChangeSetResult(data_model::ChangeSetType::kPartial, + {FlagChange{"flagA", MakeFlag(2, Value("a2"))}}, + MakeSelector(2, "state-2"))); + + std::vector> synchronizers; + synchronizers.push_back(std::make_unique( + std::make_unique(std::move(results)))); + + auto source = h.MakeDataSource({}, std::move(synchronizers)); + source->Start(); + h.Context().run(); + + ASSERT_TRUE(h.Store().Get("flagA")); + EXPECT_EQ(Value("a2"), h.Store().Get("flagA")->item->Detail().Value()); +} + +// The synchronizer asks the service for changes since the data the store +// already holds. +TEST(ClientFDv2DataSourceTest, SynchronizerReceivesTheStoresSelector) { + Harness h; + std::vector next_calls; + + std::vector results; + results.push_back( + MakeChangeSetResult(data_model::ChangeSetType::kFull, + {FlagChange{"flagA", MakeFlag(1, Value("a"))}}, + MakeSelector(1, "state-1"))); + + std::vector> synchronizers; + synchronizers.push_back(std::make_unique( + std::make_unique(std::move(results), nullptr, + &next_calls))); + + auto source = h.MakeDataSource({}, std::move(synchronizers)); + source->Start(); + h.Context().run(); + + ASSERT_GE(next_calls.size(), 2u); + EXPECT_FALSE(next_calls[0].value.has_value()); + ASSERT_TRUE(next_calls[1].value.has_value()); + EXPECT_EQ("state-1", next_calls[1].value->state); +} + +TEST(ClientFDv2DataSourceTest, InterruptedSynchronizerKeepsRunning) { + Harness h; + + std::vector results; + results.push_back( + MakeErrorResult(FDv2SourceResult::Interrupted{MakeError("boom")})); + results.push_back( + MakeChangeSetResult(data_model::ChangeSetType::kFull, + {FlagChange{"flagA", MakeFlag(1, Value("a"))}}, + MakeSelector(1, "state-1"))); + + std::vector> synchronizers; + synchronizers.push_back(std::make_unique( + std::make_unique(std::move(results)))); + + auto source = h.MakeDataSource({}, std::move(synchronizers)); + source->Start(); + h.Context().run(); + + EXPECT_EQ(DataSourceStatus::DataSourceState::kValid, h.State()); + EXPECT_TRUE(h.Store().Get("flagA")); +} + +TEST(ClientFDv2DataSourceTest, TerminalErrorAdvancesToTheNextSynchronizer) { + Harness h; + + std::vector first_results; + first_results.push_back( + MakeErrorResult(FDv2SourceResult::TerminalError{MakeError("gone")})); + + std::vector second_results; + second_results.push_back( + MakeChangeSetResult(data_model::ChangeSetType::kFull, + {FlagChange{"flagA", MakeFlag(1, Value("a"))}}, + MakeSelector(1, "state-1"))); + + std::vector> synchronizers; + synchronizers.push_back(std::make_unique( + std::make_unique(std::move(first_results)))); + auto second = std::make_unique( + std::make_unique(std::move(second_results))); + auto* second_ptr = second.get(); + synchronizers.push_back(std::move(second)); + + auto source = h.MakeDataSource({}, std::move(synchronizers)); + source->Start(); + h.Context().run(); + + EXPECT_EQ(1, second_ptr->build_count_); + EXPECT_TRUE(h.Store().Get("flagA")); +} + +TEST(ClientFDv2DataSourceTest, ExhaustedSynchronizersShutDown) { + Harness h; + + std::vector results; + results.push_back( + MakeErrorResult(FDv2SourceResult::TerminalError{MakeError("gone")})); + + std::vector> synchronizers; + synchronizers.push_back(std::make_unique( + std::make_unique(std::move(results)))); + + auto source = h.MakeDataSource({}, std::move(synchronizers)); + source->Start(); + h.Context().run(); + + EXPECT_EQ(DataSourceStatus::DataSourceState::kShutdown, h.State()); +} + +// ============================================================================ +// Environment ID +// ============================================================================ + +TEST(ClientFDv2DataSourceTest, RecordsTheEnvironmentIdFromAResult) { + Harness h; + + auto result = + MakeChangeSetResult(data_model::ChangeSetType::kFull, + {FlagChange{"flagA", MakeFlag(1, Value("a"))}}, + MakeSelector(1, "state-1")); + result.environment_id = "env-1234"; + + std::vector> initializers; + initializers.push_back(std::make_unique( + std::make_unique(std::move(result)))); + + auto source = h.MakeDataSource(std::move(initializers), {}); + source->Start(); + h.Context().run(); + + ASSERT_TRUE(source->EnvironmentId().has_value()); + EXPECT_EQ("env-1234", *source->EnvironmentId()); +} + +TEST(ClientFDv2DataSourceTest, ReportsNoEnvironmentIdUntilOneArrives) { + Harness h; + auto source = h.MakeDataSource({}, {}); + + source->Start(); + + EXPECT_FALSE(source->EnvironmentId().has_value()); +} + +// ============================================================================ +// Fallback and recovery +// ============================================================================ + +TEST(ClientFDv2DataSourceTest, SustainedInterruptionFallsBackToTheNextTier) { + Harness h; + + std::vector first_results; + first_results.push_back( + MakeErrorResult(FDv2SourceResult::Interrupted{MakeError("flaky")})); + + std::vector second_results; + second_results.push_back( + MakeChangeSetResult(data_model::ChangeSetType::kFull, + {FlagChange{"flagA", MakeFlag(1, Value("a"))}}, + MakeSelector(1, "state-1"))); + + std::vector> synchronizers; + synchronizers.push_back(std::make_unique( + std::make_unique(std::move(first_results), nullptr, + nullptr, + /* stall_after_results= */ true))); + auto second = std::make_unique( + std::make_unique(std::move(second_results))); + auto* second_ptr = second.get(); + synchronizers.push_back(std::move(second)); + + auto source = h.MakeDataSource({}, std::move(synchronizers), + std::make_unique( + h.Context().get_executor(), 50ms)); + source->Start(); + h.Context().run(); + + EXPECT_EQ(1, second_ptr->build_count_); + EXPECT_TRUE(h.Store().Get("flagA")); +} + +TEST(ClientFDv2DataSourceTest, RecoveryReturnsToThePreferredTier) { + Harness h; + + // Interrupt, then stall so that the fallback condition wins the race. + std::vector first_attempt; + first_attempt.push_back( + MakeErrorResult(FDv2SourceResult::Interrupted{MakeError("flaky")})); + + std::vector> preferred_sources; + preferred_sources.push_back(std::make_unique( + std::move(first_attempt), nullptr, nullptr, + /* stall_after_results= */ true)); + preferred_sources.push_back( + std::make_unique(std::vector{})); + + std::vector> synchronizers; + auto preferred = std::make_unique( + std::move(preferred_sources)); + auto* preferred_ptr = preferred.get(); + synchronizers.push_back(std::move(preferred)); + synchronizers.push_back(std::make_unique( + std::make_unique(std::vector{}, + nullptr, nullptr, + /* stall_after_results= */ true))); + + auto source = h.MakeDataSource({}, std::move(synchronizers), + std::make_unique( + h.Context().get_executor(), 50ms), + std::make_unique( + h.Context().get_executor(), 50ms)); + source->Start(); + h.Context().run(); + + EXPECT_EQ(2, preferred_ptr->build_count_); +} + +// ============================================================================ +// Cache-sourced data +// ============================================================================ + +// The store needs to know which data came from the cache, so that it is not +// written straight back to the cache it was read from. +TEST(ClientFDv2DataSourceTest, CacheSourcedDataIsMarkedAsSuch) { + Harness h; + + std::vector> initializers; + initializers.push_back(std::make_unique( + std::make_unique( + MakeChangeSetResult(data_model::ChangeSetType::kFull, + {FlagChange{"cached", MakeFlag(1, Value("a"))}}, + data_model::Selector{})), + /* from_cache= */ true)); + initializers.push_back(std::make_unique( + std::make_unique( + MakeChangeSetResult(data_model::ChangeSetType::kFull, + {FlagChange{"live", MakeFlag(1, Value("b"))}}, + MakeSelector(1, "state-1"))))); + + auto source = h.MakeDataSource(std::move(initializers), {}); + source->Start(); + h.Context().run(); + + ASSERT_EQ(2u, h.Applies().size()); + EXPECT_TRUE(h.Applies()[0]); + EXPECT_FALSE(h.Applies()[1]); +} diff --git a/libs/client-sdk/tests/fdv2_polling_impl_test.cpp b/libs/client-sdk/tests/fdv2_polling_impl_test.cpp new file mode 100644 index 000000000..0e01cf09c --- /dev/null +++ b/libs/client-sdk/tests/fdv2_polling_impl_test.cpp @@ -0,0 +1,267 @@ +#include + +#include + +#include +#include +#include +#include + +using namespace launchdarkly; +using namespace launchdarkly::client_side::data_sources; +using namespace std::chrono_literals; + +// A flag-eval put-object followed by payload-transferred: the smallest +// response that yields a full data set. +static char const* const kFullTransferBody = + R"({"events":[)" + R"({"event":"server-intent","data":{"payloads":[)" + R"({"id":"p1","target":1,"intentCode":"xfer-full"}]}},)" + R"({"event":"put-object","data":{"version":7,"kind":"flag-eval",)" + R"("key":"my-flag","object":{"value":"a","variation":1}}},)" + R"({"event":"payload-transferred","data":{"state":"abc","version":7}})" + R"(]})"; + +static Logger MakeNullLogger() { + struct NullBackend : ILogBackend { + bool Enabled(LogLevel) noexcept override { return false; } + void Write(LogLevel, std::string) noexcept override {} + }; + return Logger{std::make_shared()}; +} + +static FDv2SourceResult HandleResponse( + unsigned status, + std::optional body, + network::HttpResult::HeadersType headers) { + auto logger = MakeNullLogger(); + FDv2ProtocolHandler handler; + network::HttpResult res{status, std::move(body), std::move(headers)}; + return HandleFDv2PollResponse(res, &handler, logger, "test"); +} + +static FDv2RequestConfig MakeConfig(std::string base_url, + FDv2ContextTransport transport, + bool with_reasons) { + return FDv2RequestConfig{ + std::move(base_url), + config::shared::Defaults::HttpProperties(), + R"({"kind":"user","key":"user-key"})", transport, with_reasons}; +} + +TEST(ClientMakeFDv2PollRequestTest, EncodesTheContextIntoTheGetPath) { + auto req = MakeFDv2PollRequest( + MakeConfig("http://example.com", FDv2ContextTransport::kGetPath, false), + data_model::Selector{}); + + EXPECT_EQ(network::HttpMethod::kGet, req.Method()); + // The context, base64url-encoded, is the final path segment. + EXPECT_EQ( + "http://example.com/sdk/poll/eval/" + "eyJraW5kIjoidXNlciIsImtleSI6InVzZXIta2V5In0=", + req.Url()); + EXPECT_FALSE(req.Body().has_value()); +} + +TEST(ClientMakeFDv2PollRequestTest, EncodesTheContextInThePostBody) { + auto req = + MakeFDv2PollRequest(MakeConfig("http://example.com", + FDv2ContextTransport::kPostBody, false), + data_model::Selector{}); + + // POST carries the context in the body, so the path has no context segment. + EXPECT_EQ(network::HttpMethod::kPost, req.Method()); + EXPECT_EQ("http://example.com/sdk/poll/eval", req.Url()); + ASSERT_TRUE(req.Body().has_value()); + EXPECT_EQ(R"({"kind":"user","key":"user-key"})", *req.Body()); + EXPECT_EQ("application/json", + req.Properties().BaseHeaders().at("content-type")); +} + +TEST(ClientMakeFDv2PollRequestTest, EncodesANonEmptySelectorAsTheBasis) { + auto req = MakeFDv2PollRequest( + MakeConfig("http://example.com", FDv2ContextTransport::kPostBody, + false), + data_model::Selector{data_model::Selector::State{3, "state-3"}}); + + EXPECT_EQ("http://example.com/sdk/poll/eval?basis=state-3", req.Url()); +} + +TEST(ClientMakeFDv2PollRequestTest, RequestsReasonsWhenConfigured) { + auto req = MakeFDv2PollRequest( + MakeConfig("http://example.com", FDv2ContextTransport::kPostBody, true), + data_model::Selector{}); + + EXPECT_EQ("http://example.com/sdk/poll/eval?withReasons=true", req.Url()); +} + +TEST(ClientMakeFDv2PollRequestTest, OmitsTheConditionalRequestValidator) { + auto req = MakeFDv2PollRequest( + MakeConfig("http://example.com", FDv2ContextTransport::kGetPath, false), + data_model::Selector{data_model::Selector::State{3, "state-3"}}); + + EXPECT_EQ(0u, req.Properties().BaseHeaders().count("if-none-match")); + EXPECT_EQ(0u, req.Properties().BaseHeaders().count("If-None-Match")); +} + +TEST(ClientMakeFDv2PollRequestTest, BaseWithTrailingSlashJoinsCleanly) { + auto req = + MakeFDv2PollRequest(MakeConfig("http://example.com/", + FDv2ContextTransport::kPostBody, false), + data_model::Selector{}); + + EXPECT_EQ("http://example.com/sdk/poll/eval", req.Url()); +} + +TEST(ClientMakeFDv2PollRequestTest, BaseWithSubpathJoinsCleanly) { + auto req = + MakeFDv2PollRequest(MakeConfig("http://example.com/relay/", + FDv2ContextTransport::kPostBody, false), + data_model::Selector{}); + + EXPECT_EQ("http://example.com/relay/sdk/poll/eval", req.Url()); +} + +TEST(ClientMakeFDv2PollRequestTest, + UnparseableBaseUrlProducesAnInvalidRequest) { + auto req = MakeFDv2PollRequest( + MakeConfig("not a url", FDv2ContextTransport::kGetPath, false), + data_model::Selector{}); + + EXPECT_FALSE(req.Valid()); +} + +TEST(ClientHandleFDv2PollResponseTest, TranslatesAFullTransferToAChangeSet) { + auto result = HandleResponse(200, kFullTransferBody, {}); + + auto* change_set = std::get_if(&result.value); + ASSERT_NE(nullptr, change_set); + EXPECT_EQ(data_model::ChangeSetType::kFull, change_set->change_set.type); + ASSERT_EQ(1u, change_set->change_set.data.size()); + EXPECT_EQ("my-flag", change_set->change_set.data[0].key); + ASSERT_TRUE(change_set->change_set.selector.value.has_value()); + EXPECT_EQ("abc", change_set->change_set.selector.value->state); +} + +TEST(ClientHandleFDv2PollResponseTest, TreatsNotModifiedAsANoneIntent) { + auto result = HandleResponse(304, std::nullopt, {}); + + // 304 carries no body and surfaces as a none changeset. + auto* change_set = std::get_if(&result.value); + ASSERT_NE(nullptr, change_set); + EXPECT_EQ(data_model::ChangeSetType::kNone, change_set->change_set.type); + EXPECT_TRUE(change_set->change_set.data.empty()); +} + +TEST(ClientHandleFDv2PollResponseTest, ReportsTheEnvironmentId) { + auto result = + HandleResponse(200, kFullTransferBody, {{"X-LD-EnvId", "env-1234"}}); + + ASSERT_TRUE(result.environment_id.has_value()); + EXPECT_EQ("env-1234", *result.environment_id); +} + +TEST(ClientHandleFDv2PollResponseTest, ReportsNoEnvironmentIdWhenAbsent) { + auto result = HandleResponse(200, kFullTransferBody, {}); + + EXPECT_FALSE(result.environment_id.has_value()); +} + +TEST(ClientHandleFDv2PollResponseTest, RecoverableStatusIsInterrupted) { + auto result = HandleResponse(500, std::nullopt, {}); + + // A 500 is recoverable, so it interrupts rather than terminates. + EXPECT_TRUE( + std::holds_alternative(result.value)); +} + +TEST(ClientHandleFDv2PollResponseTest, UnrecoverableStatusIsTerminal) { + auto result = HandleResponse(401, std::nullopt, {}); + + // A 401 is not recoverable, so the source terminates. + EXPECT_TRUE( + std::holds_alternative(result.value)); +} + +TEST(ClientHandleFDv2PollResponseTest, NetworkErrorIsInterrupted) { + auto logger = MakeNullLogger(); + FDv2ProtocolHandler handler; + network::HttpResult res{std::optional{"connection refused"}}; + + auto result = HandleFDv2PollResponse(res, &handler, logger, "test"); + + EXPECT_TRUE( + std::holds_alternative(result.value)); + // A transport error carries no response headers, so no fallback directive. + EXPECT_FALSE(result.fdv1_fallback.has_value()); +} + +TEST(ClientHandleFDv2PollResponseTest, AbandonsAnUntranslatableChangeSet) { + // The put-object's object field is an array, not an object. + std::string const body = + R"({"events":[)" + R"({"event":"server-intent","data":{"payloads":[)" + R"({"id":"p1","target":1,"intentCode":"xfer-full"}]}},)" + R"({"event":"put-object","data":{"version":7,"kind":"flag-eval",)" + R"("key":"my-flag","object":["not-an-object"]}},)" + R"({"event":"payload-transferred","data":{"state":"abc","version":7}})" + R"(]})"; + + auto result = HandleResponse(200, body, {}); + + // The whole payload is abandoned, surfacing as a recoverable interruption. + EXPECT_TRUE( + std::holds_alternative(result.value)); +} + +TEST(ClientHandleFDv2PollResponseTest, ReadsTheFDv1FallbackDirective) { + auto result = HandleResponse( + 200, kFullTransferBody, + {{"X-LD-FD-Fallback", "true"}, {"X-LD-FD-Fallback-TTL", "120"}}); + + // The payload that arrived with the directive is still applied. + EXPECT_TRUE( + std::holds_alternative(result.value)); + ASSERT_TRUE(result.fdv1_fallback.has_value()); + EXPECT_EQ(120s, result.fdv1_fallback->ttl); +} + +TEST(ClientHandleFDv2PollResponseTest, FDv1FallbackHeaderIsCaseInsensitive) { + auto result = + HandleResponse(304, std::nullopt, {{"x-ld-fd-fallback", "TRUE"}}); + + EXPECT_TRUE(result.fdv1_fallback.has_value()); +} + +TEST(ClientHandleFDv2PollResponseTest, FDv1FallbackHeaderOtherThanTrueIgnored) { + auto result = + HandleResponse(304, std::nullopt, {{"X-LD-FD-Fallback", "false"}}); + + EXPECT_FALSE(result.fdv1_fallback.has_value()); +} + +TEST(ClientHandleFDv2PollResponseTest, FDv1FallbackTravelsWithATerminalError) { + auto result = + HandleResponse(401, std::nullopt, {{"X-LD-FD-Fallback", "true"}}); + + EXPECT_TRUE( + std::holds_alternative(result.value)); + EXPECT_TRUE(result.fdv1_fallback.has_value()); +} + +TEST(ClientHandleFDv2PollResponseTest, GoodbyeCarriesItsOwnFallbackTtl) { + std::string const body = + R"({"events":[)" + R"({"event":"goodbye","data":{"reason":"bye","protocolFallbackTTL":90}})" + R"(]})"; + + auto result = HandleResponse( + 200, body, + {{"X-LD-FD-Fallback", "true"}, {"X-LD-FD-Fallback-TTL", "120"}}); + + EXPECT_TRUE( + std::holds_alternative(result.value)); + // The goodbye's own TTL (90) wins over the header's (120). + ASSERT_TRUE(result.fdv1_fallback.has_value()); + EXPECT_EQ(90s, result.fdv1_fallback->ttl); +} diff --git a/libs/client-sdk/tests/fdv2_polling_sources_test.cpp b/libs/client-sdk/tests/fdv2_polling_sources_test.cpp new file mode 100644 index 000000000..c748b72d9 --- /dev/null +++ b/libs/client-sdk/tests/fdv2_polling_sources_test.cpp @@ -0,0 +1,107 @@ +#include + +#include +#include + +#include +#include + +#include + +using namespace launchdarkly; +using namespace launchdarkly::client_side::data_sources; +using namespace std::chrono_literals; + +static Logger MakeNullLogger() { + struct NullBackend : ILogBackend { + bool Enabled(LogLevel) noexcept override { return false; } + void Write(LogLevel, std::string) noexcept override {} + }; + return Logger{std::make_shared()}; +} + +static FDv2RequestConfig MakeConfig(std::string base_url) { + return FDv2RequestConfig{ + std::move(base_url), + config::shared::Defaults::HttpProperties(), + R"({"kind":"user","key":"user-key"})", FDv2ContextTransport::kGetPath, + false}; +} + +TEST(FDv2PollingInitializerTests, UnparseableEndpointIsATerminalError) { + boost::asio::io_context ioc; + auto logger = MakeNullLogger(); + FDv2PollingInitializer initializer(ioc.get_executor(), logger, + MakeConfig("not a url")); + + auto future = initializer.Run(); + + // A bad endpoint fails synchronously with a terminal error. + ASSERT_TRUE(future.IsFinished()); + EXPECT_TRUE(std::holds_alternative( + future.GetResult()->value)); +} + +TEST(FDv2PollingSynchronizerTests, NextRespectsTheIntervalSinceTheLastPoll) { + boost::asio::io_context ioc; + auto logger = MakeNullLogger(); + FDv2PollingSynchronizer synchronizer(ioc.get_executor(), logger, + MakeConfig("http://example.com"), 30s, + std::chrono::steady_clock::now()); + + auto future = synchronizer.Next(data_model::Selector{}); + + // The interval has not elapsed, so no request has been made and only the + // interval timer or Close can resolve the future. + EXPECT_FALSE(future.IsFinished()); +} + +TEST(FDv2PollingSynchronizerTests, CloseUnblocksAPendingNext) { + boost::asio::io_context ioc; + auto logger = MakeNullLogger(); + FDv2PollingSynchronizer synchronizer(ioc.get_executor(), logger, + MakeConfig("http://example.com"), 30s, + std::chrono::steady_clock::now()); + + auto future = synchronizer.Next(data_model::Selector{}); + synchronizer.Close(); + + // Close resolves a Next that is waiting on the interval timer. + ASSERT_TRUE(future.IsFinished()); + EXPECT_TRUE(std::holds_alternative( + future.GetResult()->value)); +} + +TEST(FDv2PollingSynchronizerTests, NextAfterCloseIsShutdown) { + boost::asio::io_context ioc; + auto logger = MakeNullLogger(); + FDv2PollingSynchronizer synchronizer(ioc.get_executor(), logger, + MakeConfig("http://example.com"), 30s, + std::nullopt); + + synchronizer.Close(); + auto future = synchronizer.Next(data_model::Selector{}); + + // A Next issued after Close resolves immediately as shutdown. + ASSERT_TRUE(future.IsFinished()); + EXPECT_TRUE(std::holds_alternative( + future.GetResult()->value)); +} + +TEST(FDv2PollingSynchronizerTests, IntervalIsClampedToTheMinimum) { + boost::asio::io_context ioc; + auto logger = MakeNullLogger(); + auto const min_interval = + launchdarkly::config::shared::Defaults< + launchdarkly::config::shared::ClientSDK>::PollingConfig() + .min_polling_interval; + FDv2PollingSynchronizer synchronizer( + ioc.get_executor(), logger, MakeConfig("http://example.com"), 1s, + std::chrono::steady_clock::now() - (min_interval - 5s)); + + auto future = synchronizer.Next(data_model::Selector{}); + + // With the configured 1s interval the poll would already be due. Clamped + // to the minimum it is not. + EXPECT_FALSE(future.IsFinished()); +} diff --git a/libs/client-sdk/tests/fdv2_source_result_test.cpp b/libs/client-sdk/tests/fdv2_source_result_test.cpp new file mode 100644 index 000000000..8751420f2 --- /dev/null +++ b/libs/client-sdk/tests/fdv2_source_result_test.cpp @@ -0,0 +1,73 @@ +#include + +#include +#include +#include + +#include +#include + +using namespace launchdarkly::client_side::data_sources; +using namespace std::chrono_literals; + +TEST(FDv1FallbackDirectiveTests, UsesAServiceSuppliedTtlAsGiven) { + // The service jitters the TTLs it supplies, so the SDK must not. + for (auto ttl : {1s, 60s, 3599s, 3600s}) { + EXPECT_EQ(ttl, FDv1FallbackDirective::FromServiceTtl(ttl).ttl); + } +} + +TEST(FDv1FallbackDirectiveTests, JittersTheDefaultWhenNoTtlIsSupplied) { + auto const directive = FDv1FallbackDirective::DefaultTtl(); + + // The jittered 1-hour default lands in [30min, 1h]. + EXPECT_GE(directive.ttl, 30min); + EXPECT_LE(directive.ttl, 1h); +} + +TEST(FDv1FallbackDirectiveTests, FallsBackToTheDefaultForOutOfRangeTtls) { + for (auto ttl : {0s, 3601s, std::chrono::seconds{24h * 7}}) { + auto const directive = FDv1FallbackDirective::FromServiceTtl(ttl); + + // The jittered 1-hour default lands in [30min, 1h]. + EXPECT_GE(directive.ttl, 30min); + EXPECT_LE(directive.ttl, 1h); + } +} + +TEST(FDv1FallbackDirectiveTests, ParsesATtlHeaderValue) { + EXPECT_EQ(120s, FDv1FallbackDirective::FromServiceTtl("120").ttl); +} + +TEST(FDv1FallbackDirectiveTests, TreatsAMalformedTtlHeaderAsAbsent) { + for (auto const* value : {"", "abc", "12.5", "60s", "-60", " 60"}) { + auto const directive = FDv1FallbackDirective::FromServiceTtl(value); + + // The jittered 1-hour default lands in [30min, 1h]. + EXPECT_GE(directive.ttl, 30min); + EXPECT_LE(directive.ttl, 1h); + } +} + +TEST(FDv1FallbackDirectiveTests, DefaultTtlJitterVaries) { + std::set observed; + for (int i = 0; i < 50; i++) { + observed.insert(FDv1FallbackDirective::DefaultTtl().ttl); + } + + EXPECT_GT(observed.size(), 1u); +} + +TEST(FDv2SourceFactoryTests, FactoriesAreNeitherCacheNorFDv1ByDefault) { + class Initializers final : public IFDv2InitializerFactory { + public: + std::unique_ptr Build() override { return nullptr; } + }; + class Synchronizers final : public IFDv2SynchronizerFactory { + public: + std::unique_ptr Build() override { return nullptr; } + }; + + EXPECT_FALSE(Initializers{}.IsFromCache()); + EXPECT_FALSE(Synchronizers{}.IsFDv1Fallback()); +} diff --git a/libs/client-sdk/tests/fdv2_streaming_synchronizer_test.cpp b/libs/client-sdk/tests/fdv2_streaming_synchronizer_test.cpp new file mode 100644 index 000000000..8683c58bd --- /dev/null +++ b/libs/client-sdk/tests/fdv2_streaming_synchronizer_test.cpp @@ -0,0 +1,536 @@ +#include + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace launchdarkly::client_side::data_sources { + +// Drives the State's per-event, per-error, and per-connect entry points +// directly, so that no real SSE connection is required. +class FDv2StreamingSynchronizerTestPeer { + public: + static void OnEvent(FDv2StreamingSynchronizer& sync, + sse::Event const& event) { + sync.state_->OnEvent(event); + } + static void OnError(FDv2StreamingSynchronizer& sync, + sse::Error const& error) { + sync.state_->OnError(error); + } + static void OnConnect( + FDv2StreamingSynchronizer& sync, + boost::beast::http::request* req) { + sync.state_->OnConnect(req); + } + static void OnResponse( + FDv2StreamingSynchronizer& sync, + boost::beast::http::response_header<> const& headers) { + sync.state_->OnResponse(headers); + } + static void MarkStarted(FDv2StreamingSynchronizer& sync) { + std::lock_guard lock(sync.state_->mutex_); + sync.state_->started_ = true; + } + static void SetBaseUrl(FDv2StreamingSynchronizer& sync, + boost::urls::url url) { + std::lock_guard lock(sync.state_->mutex_); + sync.state_->base_url_ = std::move(url); + } + static void SetLatestSelector(FDv2StreamingSynchronizer& sync, + data_model::Selector selector) { + std::lock_guard lock(sync.state_->mutex_); + sync.state_->latest_selector_ = std::move(selector); + } + static void SetSseClient(FDv2StreamingSynchronizer& sync, + std::shared_ptr client) { + std::lock_guard lock(sync.state_->mutex_); + sync.state_->sse_client_ = std::move(client); + } +}; + +} // namespace launchdarkly::client_side::data_sources + +using namespace launchdarkly; +using namespace launchdarkly::client_side::data_sources; +using namespace std::chrono_literals; + +namespace { + +Logger MakeNullLogger() { + struct NullBackend : ILogBackend { + bool Enabled(LogLevel) noexcept override { return false; } + void Write(LogLevel, std::string) noexcept override {} + }; + return Logger{std::make_shared()}; +} + +FDv2RequestConfig MakeConfig( + std::string base_url, + FDv2ContextTransport transport = FDv2ContextTransport::kGetPath, + bool with_reasons = false) { + return FDv2RequestConfig{ + std::move(base_url), + config::shared::Defaults::HttpProperties(), + R"({"kind":"user","key":"user-key"})", transport, with_reasons}; +} + +boost::beast::http::response_header<> MakeResponseHeaders( + std::vector> const& headers) { + boost::beast::http::response_header<> result; + for (auto const& [name, value] : headers) { + result.set(name, value); + } + return result; +} + +class IoContextRunner { + public: + IoContextRunner() : work_guard_(boost::asio::make_work_guard(ioc_)) { + thread_ = std::thread([this] { ioc_.run(); }); + } + ~IoContextRunner() { + work_guard_.reset(); + ioc_.stop(); + if (thread_.joinable()) { + thread_.join(); + } + } + boost::asio::io_context& context() { return ioc_; } + + private: + boost::asio::io_context ioc_; + boost::asio::executor_work_guard + work_guard_; + std::thread thread_; +}; + +// Records calls to the sse::Client interface, so tests can verify how the +// synchronizer drives the connection without a real network client. +class MockSseClient : public sse::Client { + public: + void async_connect() override {} + void async_shutdown(std::function completion) override { + if (completion) { + completion(); + } + } + void async_restart(std::string const& reason) override { + ++restart_count_; + last_restart_reason_ = reason; + } + + int restart_count_ = 0; + std::string last_restart_reason_; +}; + +// Builds a synchronizer that believes it is already streaming, so that tests +// can push events at it without a connection. +struct StreamingFixture { + Logger logger = MakeNullLogger(); + IoContextRunner runner; + std::shared_ptr client = std::make_shared(); + std::unique_ptr synchronizer; + + explicit StreamingFixture(std::string poll_base_url = "http://localhost") { + synchronizer = std::make_unique( + runner.context().get_executor(), logger, + MakeConfig("https://stream.example.com"), + MakeConfig(std::move(poll_base_url)), 1s); + FDv2StreamingSynchronizerTestPeer::MarkStarted(*synchronizer); + FDv2StreamingSynchronizerTestPeer::SetSseClient(*synchronizer, client); + } + + void Push(std::string type, std::string data) { + FDv2StreamingSynchronizerTestPeer::OnEvent( + *synchronizer, sse::Event(std::move(type), std::move(data))); + } + + std::optional NextResult() { + return synchronizer->Next(data_model::Selector{}).WaitForResult(2s); + } +}; + +} // namespace + +// ============================================================================ +// Lifecycle +// ============================================================================ + +TEST(ClientFDv2StreamingSynchronizerTest, UnparseableEndpointIsTerminal) { + auto logger = MakeNullLogger(); + IoContextRunner runner; + + FDv2StreamingSynchronizer synchronizer(runner.context().get_executor(), + logger, MakeConfig("not a url"), + MakeConfig("http://localhost"), 1s); + + auto result = synchronizer.Next(data_model::Selector{}).WaitForResult(2s); + + ASSERT_TRUE(result.has_value()); + auto* terminal = + std::get_if(&result->value); + ASSERT_NE(nullptr, terminal); + EXPECT_EQ(FDv2SourceResult::ErrorInfo::ErrorKind::kNetworkError, + terminal->error.Kind()); +} + +TEST(ClientFDv2StreamingSynchronizerTest, NextAfterCloseIsShutdown) { + auto logger = MakeNullLogger(); + IoContextRunner runner; + + FDv2StreamingSynchronizer synchronizer( + runner.context().get_executor(), logger, MakeConfig("http://localhost"), + MakeConfig("http://localhost"), 1s); + synchronizer.Close(); + + auto result = synchronizer.Next(data_model::Selector{}).WaitForResult(2s); + + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE( + std::holds_alternative(result->value)); +} + +TEST(ClientFDv2StreamingSynchronizerTest, CloseUnblocksAPendingNext) { + auto logger = MakeNullLogger(); + IoContextRunner runner; + + FDv2StreamingSynchronizer synchronizer( + runner.context().get_executor(), logger, MakeConfig("http://localhost"), + MakeConfig("http://localhost"), 1s); + + // Skip the SSE setup, so that Next is pending purely on the close race + // rather than on real network activity. + FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); + + auto future = synchronizer.Next(data_model::Selector{}); + synchronizer.Close(); + auto result = future.WaitForResult(2s); + + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE( + std::holds_alternative(result->value)); +} + +// ============================================================================ +// Request construction +// ============================================================================ + +TEST(ClientFDv2StreamingSynchronizerTest, GetTargetCarriesTheEncodedContext) { + auto logger = MakeNullLogger(); + IoContextRunner runner; + + FDv2StreamingSynchronizer synchronizer( + runner.context().get_executor(), logger, + MakeConfig("https://stream.example.com"), + MakeConfig("http://localhost"), 1s); + + // The connection is not made, but the target is built during setup. + synchronizer.Next(data_model::Selector{}); + boost::beast::http::request req; + FDv2StreamingSynchronizerTestPeer::OnConnect(synchronizer, &req); + + EXPECT_EQ("/sdk/stream/eval/eyJraW5kIjoidXNlciIsImtleSI6InVzZXIta2V5In0=", + req.target()); +} + +TEST(ClientFDv2StreamingSynchronizerTest, PostTargetOmitsTheContext) { + auto logger = MakeNullLogger(); + IoContextRunner runner; + + FDv2StreamingSynchronizer synchronizer( + runner.context().get_executor(), logger, + MakeConfig("https://stream.example.com", + FDv2ContextTransport::kPostBody), + MakeConfig("http://localhost"), 1s); + + synchronizer.Next(data_model::Selector{}); + boost::beast::http::request req; + FDv2StreamingSynchronizerTestPeer::OnConnect(synchronizer, &req); + + EXPECT_EQ("/sdk/stream/eval", req.target()); +} + +TEST(ClientFDv2StreamingSynchronizerTest, TargetCarriesWithReasons) { + auto logger = MakeNullLogger(); + IoContextRunner runner; + + FDv2StreamingSynchronizer synchronizer( + runner.context().get_executor(), logger, + MakeConfig("https://stream.example.com", + FDv2ContextTransport::kPostBody, + /* with_reasons= */ true), + MakeConfig("http://localhost"), 1s); + + synchronizer.Next(data_model::Selector{}); + boost::beast::http::request req; + FDv2StreamingSynchronizerTestPeer::OnConnect(synchronizer, &req); + + EXPECT_EQ("/sdk/stream/eval?withReasons=true", req.target()); +} + +TEST(ClientFDv2StreamingSynchronizerTest, EmptySelectorSendsNoBasis) { + auto logger = MakeNullLogger(); + IoContextRunner runner; + + FDv2StreamingSynchronizer synchronizer( + runner.context().get_executor(), logger, + MakeConfig("https://stream.example.com", + FDv2ContextTransport::kPostBody), + MakeConfig("http://localhost"), 1s); + + boost::urls::url base = + boost::urls::parse_uri("https://stream.example.com/sdk/stream/eval") + .value(); + FDv2StreamingSynchronizerTestPeer::SetBaseUrl(synchronizer, base); + + boost::beast::http::request req; + FDv2StreamingSynchronizerTestPeer::OnConnect(synchronizer, &req); + + EXPECT_EQ("/sdk/stream/eval", req.target()); +} + +// Each connection attempt uses the freshest selector, which is why the basis +// is appended per connect rather than baked into the base URL. +TEST(ClientFDv2StreamingSynchronizerTest, SelectorIsSentAsTheBasisPerConnect) { + auto logger = MakeNullLogger(); + IoContextRunner runner; + + FDv2StreamingSynchronizer synchronizer( + runner.context().get_executor(), logger, + MakeConfig("https://stream.example.com", + FDv2ContextTransport::kPostBody), + MakeConfig("http://localhost"), 1s); + + boost::urls::url base = + boost::urls::parse_uri("https://stream.example.com/sdk/stream/eval") + .value(); + FDv2StreamingSynchronizerTestPeer::SetBaseUrl(synchronizer, base); + FDv2StreamingSynchronizerTestPeer::SetLatestSelector( + synchronizer, + data_model::Selector{data_model::Selector::State{3, "state-3"}}); + + boost::beast::http::request req; + FDv2StreamingSynchronizerTestPeer::OnConnect(synchronizer, &req); + + EXPECT_EQ("/sdk/stream/eval?basis=state-3", req.target()); +} + +// ============================================================================ +// Events +// ============================================================================ + +TEST(ClientFDv2StreamingSynchronizerTest, FullTransferBecomesAChangeSet) { + StreamingFixture f; + + f.Push("server-intent", R"({"payloads":[{"id":"p1","target":1,)" + R"("intentCode":"xfer-full"}]})"); + f.Push("put-object", R"({"version":7,"kind":"flag-eval","key":"my-flag",)" + R"("object":{"value":"a","variation":1}})"); + f.Push("payload-transferred", R"({"state":"abc","version":7})"); + + auto result = f.NextResult(); + + ASSERT_TRUE(result.has_value()); + auto* change_set = std::get_if(&result->value); + ASSERT_NE(nullptr, change_set); + ASSERT_EQ(1u, change_set->change_set.data.size()); + EXPECT_EQ("my-flag", change_set->change_set.data[0].key); + ASSERT_TRUE(change_set->change_set.selector.value.has_value()); + EXPECT_EQ("abc", change_set->change_set.selector.value->state); +} + +TEST(ClientFDv2StreamingSynchronizerTest, + GoodbyeReportsReconnectsAndCarriesItsTtl) { + StreamingFixture f; + + f.Push("goodbye", R"({"reason":"bye","protocolFallbackTTL":90})"); + auto result = f.NextResult(); + + ASSERT_TRUE(result.has_value()); + auto* goodbye = std::get_if(&result->value); + ASSERT_NE(nullptr, goodbye); + EXPECT_EQ("bye", goodbye->reason.value_or("")); + EXPECT_EQ(1, f.client->restart_count_); + EXPECT_EQ("FDv2 goodbye received", f.client->last_restart_reason_); + ASSERT_TRUE(result->fdv1_fallback.has_value()); + EXPECT_EQ(90s, result->fdv1_fallback->ttl); +} + +TEST(ClientFDv2StreamingSynchronizerTest, UnparseableEventDataReconnects) { + StreamingFixture f; + + f.Push("put-object", "{not json"); + auto result = f.NextResult(); + + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE( + std::holds_alternative(result->value)); + EXPECT_EQ(1, f.client->restart_count_); +} + +TEST(ClientFDv2StreamingSynchronizerTest, + UntranslatableChangeSetResetsTheHandlerAndReconnects) { + StreamingFixture f; + + f.Push("server-intent", R"({"payloads":[{"id":"p1","target":1,)" + R"("intentCode":"xfer-full"}]})"); + f.Push("put-object", R"({"version":7,"kind":"flag-eval","key":"my-flag",)" + R"("object":["not-an-object"]})"); + f.Push("payload-transferred", R"({"state":"abc","version":7})"); + + auto result = f.NextResult(); + + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE( + std::holds_alternative(result->value)); + // The connection is dropped and reconnected for a fresh basis. + EXPECT_EQ(1, f.client->restart_count_); + + // A payload-transferred is only valid mid-transfer, i.e. after a + // server-intent. The reset ended the transfer, so this one is now a + // protocol error. Without the reset the handler would still be mid-transfer + // and would emit a partial changeset over the data we just discarded. + f.Push("payload-transferred", R"({"state":"def","version":8})"); + auto after = f.NextResult(); + + ASSERT_TRUE(after.has_value()); + EXPECT_TRUE( + std::holds_alternative(after->value)); +} + +TEST(ClientFDv2StreamingSynchronizerTest, UnrecognizedEventIsIgnored) { + StreamingFixture f; + + f.Push("something-new", R"({"anything":true})"); + + // Nothing but a real result or Close can resolve the future. + auto future = f.synchronizer->Next(data_model::Selector{}); + EXPECT_FALSE(future.IsFinished()); +} + +TEST(ClientFDv2StreamingSynchronizerTest, PingTriggersAPoll) { + // An invalid URL makes the answering request observable without network. + StreamingFixture f("not a url"); + + f.Push("ping", ""); + auto result = f.NextResult(); + + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE( + std::holds_alternative(result->value)); + EXPECT_EQ(0, f.client->restart_count_); +} + +// ============================================================================ +// Response headers +// ============================================================================ + +TEST(ClientFDv2StreamingSynchronizerTest, ResultsCarryTheEnvironmentId) { + StreamingFixture f; + + FDv2StreamingSynchronizerTestPeer::OnResponse( + *f.synchronizer, MakeResponseHeaders({{"X-LD-EnvId", "env-1234"}})); + f.Push("goodbye", R"({"reason":"bye"})"); + + auto result = f.NextResult(); + + ASSERT_TRUE(result.has_value()); + ASSERT_TRUE(result->environment_id.has_value()); + EXPECT_EQ("env-1234", *result->environment_id); +} + +TEST(ClientFDv2StreamingSynchronizerTest, ResultsCarryTheFallbackDirective) { + StreamingFixture f; + + FDv2StreamingSynchronizerTestPeer::OnResponse( + *f.synchronizer, + MakeResponseHeaders( + {{"X-LD-FD-Fallback", "true"}, {"X-LD-FD-Fallback-TTL", "120"}})); + f.Push("server-intent", R"({"payloads":[{"id":"p1","target":1,)" + R"("intentCode":"xfer-full"}]})"); + f.Push("payload-transferred", R"({"state":"abc","version":7})"); + + auto result = f.NextResult(); + + ASSERT_TRUE(result.has_value()); + ASSERT_TRUE(result->fdv1_fallback.has_value()); + EXPECT_EQ(120s, result->fdv1_fallback->ttl); +} + +TEST(ClientFDv2StreamingSynchronizerTest, GoodbyeTtlWinsOverTheHeader) { + StreamingFixture f; + + FDv2StreamingSynchronizerTestPeer::OnResponse( + *f.synchronizer, + MakeResponseHeaders( + {{"X-LD-FD-Fallback", "true"}, {"X-LD-FD-Fallback-TTL", "120"}})); + f.Push("goodbye", R"({"reason":"bye","protocolFallbackTTL":90})"); + + auto result = f.NextResult(); + + ASSERT_TRUE(result.has_value()); + // The goodbye's own TTL (90) wins over the header's (120). + ASSERT_TRUE(result->fdv1_fallback.has_value()); + EXPECT_EQ(90s, result->fdv1_fallback->ttl); +} + +TEST(ClientFDv2StreamingSynchronizerTest, ReconnectWithoutTheHeaderClearsIt) { + StreamingFixture f; + + FDv2StreamingSynchronizerTestPeer::OnResponse( + *f.synchronizer, MakeResponseHeaders({{"X-LD-FD-Fallback", "true"}})); + FDv2StreamingSynchronizerTestPeer::OnResponse(*f.synchronizer, + MakeResponseHeaders({})); + f.Push("goodbye", R"({"reason":"bye"})"); + + auto result = f.NextResult(); + + ASSERT_TRUE(result.has_value()); + EXPECT_FALSE(result->fdv1_fallback.has_value()); +} + +// ============================================================================ +// Errors +// ============================================================================ + +TEST(ClientFDv2StreamingSynchronizerTest, RecoverableSseErrorIsInterrupted) { + StreamingFixture f; + + FDv2StreamingSynchronizerTestPeer::OnError(*f.synchronizer, + sse::errors::ReadTimeout{100ms}); + auto result = f.NextResult(); + + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE( + std::holds_alternative(result->value)); +} + +TEST(ClientFDv2StreamingSynchronizerTest, UnrecoverableSseErrorIsTerminal) { + StreamingFixture f; + + FDv2StreamingSynchronizerTestPeer::OnError( + *f.synchronizer, sse::errors::UnrecoverableClientError{ + boost::beast::http::status::unauthorized}); + auto result = f.NextResult(); + + ASSERT_TRUE(result.has_value()); + auto* terminal = + std::get_if(&result->value); + ASSERT_NE(nullptr, terminal); + EXPECT_EQ(401u, terminal->error.StatusCode()); +} diff --git a/libs/client-sdk/tests/flag_persistence_test.cpp b/libs/client-sdk/tests/flag_persistence_test.cpp index 0ff1edb17..0139dedf0 100644 --- a/libs/client-sdk/tests/flag_persistence_test.cpp +++ b/libs/client-sdk/tests/flag_persistence_test.cpp @@ -11,11 +11,15 @@ using launchdarkly::ContextBuilder; using launchdarkly::EvaluationDetailInternal; using launchdarkly::EvaluationResult; using launchdarkly::Value; +using launchdarkly::client_side::FlagChange; +using launchdarkly::client_side::FlagChangeSet; using launchdarkly::client_side::ItemDescriptor; using launchdarkly::client_side::flag_manager::FlagPersistence; using launchdarkly::client_side::flag_manager::FlagStore; using launchdarkly::client_side::flag_manager::FlagUpdater; using launchdarkly::client_side::flag_manager::PersistenceEncodeKey; +using launchdarkly::data_model::ChangeSetType; +using launchdarkly::data_model::Selector; class TestPersistence : public IPersistence { public: @@ -172,3 +176,209 @@ TEST(FlagPersistenceTests, EvictsContextsBeyondMax) { // Sha256 potato:susan-key EXPECT_EQ(1, space.count(PersistenceEncodeKey("potato:susan-key"))); } + +TEST(FlagPersistenceTests, StoresCacheOnApply) { + auto context = ContextBuilder().Kind("user", "user-key").Build(); + auto store = FlagStore(); + auto updater = FlagUpdater(store); + auto persistence = + std::make_shared(TestPersistence::StoreType()); + auto logger = launchdarkly::logging::NullLogger(); + + FlagPersistence flag_persistence( + "the-key", updater, store, persistence, logger, 5, []() { + return std::chrono::system_clock::time_point{ + std::chrono::milliseconds{500}}; + }); + + // Apply a full changeset that did not come from the cache. + flag_persistence.Apply( + context, + FlagChangeSet{ + ChangeSetType::kFull, + {FlagChange{"flagA", + ItemDescriptor{EvaluationResult{ + 1, std::nullopt, false, false, std::nullopt, + EvaluationDetailInternal{ + Value("test"), std::nullopt, std::nullopt}}}}}, + Selector{}}, + /* from_cache= */ false); + + // The applied flag is written to the cache. + EXPECT_EQ(R"({"flagA":{"version":1,"value":"test"}})", + persistence->store_ + ["LaunchDarkly_rUTcjlHPv6Vegd27YmtGYkEGkEUGaEbn5M0JYTFQUpA="] + ["CEXjZY7cHJG_ydFy7q4-YEFwVrG3_pkJwA4FAjrbfx0="]); +} + +TEST(FlagPersistenceTests, ApplyOfNoneChangeSetDoesNotWriteTheFlagData) { + auto context = ContextBuilder().Kind("user", "user-key").Build(); + auto store = FlagStore(); + auto updater = FlagUpdater(store); + auto persistence = + std::make_shared(TestPersistence::StoreType()); + auto logger = launchdarkly::logging::NullLogger(); + + FlagPersistence flag_persistence("the-key", updater, store, persistence, + logger, 5); + + // Apply a "none" changeset. + flag_persistence.Apply(context, + FlagChangeSet{ChangeSetType::kNone, {}, Selector{}}, + /* from_cache= */ false); + + // The context's flag data is not written. + auto& space = persistence->store_.begin()->second; + EXPECT_EQ(0, space.count(PersistenceEncodeKey("user:user-key"))); +} + +TEST(FlagPersistenceTests, RecordsFreshnessOnAPayload) { + auto context = ContextBuilder().Kind("user", "user-key").Build(); + auto store = FlagStore(); + auto updater = FlagUpdater(store); + auto persistence = + std::make_shared(TestPersistence::StoreType()); + auto logger = launchdarkly::logging::NullLogger(); + + FlagPersistence flag_persistence( + "the-key", updater, store, persistence, logger, 5, []() { + return std::chrono::system_clock::time_point{ + std::chrono::milliseconds{500}}; + }); + + EXPECT_FALSE(flag_persistence.ReadFreshness(context).has_value()); + + flag_persistence.Apply( + context, + FlagChangeSet{ + ChangeSetType::kFull, + {FlagChange{"flagA", + ItemDescriptor{EvaluationResult{ + 1, std::nullopt, false, false, std::nullopt, + EvaluationDetailInternal{ + Value("test"), std::nullopt, std::nullopt}}}}}, + Selector{}}, + /* from_cache= */ false); + + EXPECT_EQ( + std::chrono::system_clock::time_point{std::chrono::milliseconds{500}}, + flag_persistence.ReadFreshness(context)); +} + +// A "none" intent is the service confirming the SDK's data is current, which +// is exactly as good as receiving it again. +TEST(FlagPersistenceTests, RecordsFreshnessOnANoneChangeSet) { + auto context = ContextBuilder().Kind("user", "user-key").Build(); + auto store = FlagStore(); + auto updater = FlagUpdater(store); + auto persistence = + std::make_shared(TestPersistence::StoreType()); + auto logger = launchdarkly::logging::NullLogger(); + + FlagPersistence flag_persistence( + "the-key", updater, store, persistence, logger, 5, []() { + return std::chrono::system_clock::time_point{ + std::chrono::milliseconds{700}}; + }); + + flag_persistence.Apply(context, + FlagChangeSet{ChangeSetType::kNone, {}, Selector{}}, + /* from_cache= */ false); + + EXPECT_EQ( + std::chrono::system_clock::time_point{std::chrono::milliseconds{700}}, + flag_persistence.ReadFreshness(context)); +} + +// The freshness record is keyed by the whole context, because changing an +// attribute can change how flags evaluate. +TEST(FlagPersistenceTests, FreshnessIsPerContextAttributeSet) { + auto store = FlagStore(); + auto updater = FlagUpdater(store); + auto persistence = + std::make_shared(TestPersistence::StoreType()); + auto logger = launchdarkly::logging::NullLogger(); + + FlagPersistence flag_persistence( + "the-key", updater, store, persistence, logger, 5, []() { + return std::chrono::system_clock::time_point{ + std::chrono::milliseconds{500}}; + }); + + auto plain = ContextBuilder().Kind("user", "user-key").Build(); + auto with_attribute = + ContextBuilder().Kind("user", "user-key").Set("country", "US").Build(); + + flag_persistence.Apply(plain, + FlagChangeSet{ChangeSetType::kNone, {}, Selector{}}, + /* from_cache= */ false); + + EXPECT_TRUE(flag_persistence.ReadFreshness(plain).has_value()); + EXPECT_FALSE(flag_persistence.ReadFreshness(with_attribute).has_value()); +} + +// A stored context that has aged out of the cache should not keep a freshness +// record alive either. +TEST(FlagPersistenceTests, PrunesFreshnessBeyondMaxContexts) { + auto store = FlagStore(); + auto updater = FlagUpdater(store); + auto persistence = + std::make_shared(TestPersistence::StoreType()); + auto logger = launchdarkly::logging::NullLogger(); + + uint64_t now = 0; + FlagPersistence flag_persistence( + "the-key", updater, store, persistence, logger, 2, [&now]() { + return std::chrono::system_clock::time_point{ + std::chrono::milliseconds{now}}; + }); + + auto first = ContextBuilder().Kind("user", "first").Build(); + for (auto const& key : {"first", "second", "third"}) { + flag_persistence.Apply( + ContextBuilder().Kind("user", key).Build(), + FlagChangeSet{ChangeSetType::kNone, {}, Selector{}}, + /* from_cache= */ false); + now++; + } + + EXPECT_FALSE(flag_persistence.ReadFreshness(first).has_value()); + EXPECT_TRUE( + flag_persistence + .ReadFreshness(ContextBuilder().Kind("user", "third").Build()) + .has_value()); +} + +// Data read out of the cache was never confirmed current by the service, so +// writing it back or counting it as fresh would be misleading. +TEST(FlagPersistenceTests, ApplyFromCacheDoesNotWriteTheCache) { + auto context = ContextBuilder().Kind("user", "user-key").Build(); + auto store = FlagStore(); + auto updater = FlagUpdater(store); + auto persistence = + std::make_shared(TestPersistence::StoreType()); + auto logger = launchdarkly::logging::NullLogger(); + + FlagPersistence flag_persistence("the-key", updater, store, persistence, + logger, 5); + + // Apply a changeset that came from the cache. + flag_persistence.Apply( + context, + FlagChangeSet{ + ChangeSetType::kFull, + {FlagChange{"flagA", + ItemDescriptor{EvaluationResult{ + 1, std::nullopt, false, false, std::nullopt, + EvaluationDetailInternal{ + Value("test"), std::nullopt, std::nullopt}}}}}, + Selector{}}, + /* from_cache= */ true); + + // Nothing is written back. + EXPECT_TRUE(persistence->store_.empty()); + // Nor was it confirmed current by the service, so it is not freshness. + EXPECT_FALSE(flag_persistence.ReadFreshness(context).has_value()); + // The data is still applied to the store, so evaluation can use it. + ASSERT_TRUE(store.Get("flagA")); +} diff --git a/libs/client-sdk/tests/flag_store_apply_test.cpp b/libs/client-sdk/tests/flag_store_apply_test.cpp new file mode 100644 index 000000000..f43ae82fb --- /dev/null +++ b/libs/client-sdk/tests/flag_store_apply_test.cpp @@ -0,0 +1,399 @@ +#include + +#include +#include + +#include "data_sources/data_source_update_sink.hpp" +#include "flag_manager/flag_store.hpp" + +using launchdarkly::EvaluationDetailInternal; +using launchdarkly::EvaluationResult; +using launchdarkly::Value; +using launchdarkly::client_side::FlagChange; +using launchdarkly::client_side::FlagChangeSet; +using launchdarkly::client_side::ItemDescriptor; +using launchdarkly::client_side::flag_manager::FlagStore; +using launchdarkly::data_model::ChangeSetType; +using launchdarkly::data_model::Selector; +using Tombstone = launchdarkly::data_model::Tombstone; + +static ItemDescriptor Flag(std::uint64_t version, Value value) { + return ItemDescriptor{ + EvaluationResult{version, std::nullopt, false, false, std::nullopt, + EvaluationDetailInternal{std::move(value), + std::nullopt, std::nullopt}}}; +} + +static Selector SelectorAt(std::int64_t version, std::string state) { + return Selector{Selector::State{version, std::move(state)}}; +} + +TEST(FlagStoreApplyTests, FullChangeSetReplacesAllData) { + FlagStore store; + + store.Apply(FlagChangeSet{ChangeSetType::kFull, + {FlagChange{"flagA", Flag(1, Value("a"))}, + FlagChange{"flagB", Flag(1, Value("b"))}}, + Selector{}}, + /* compute_changes= */ false); + + // A second full changeset carrying only flagB. + store.Apply(FlagChangeSet{ChangeSetType::kFull, + {FlagChange{"flagB", Flag(2, Value("b2"))}}, + Selector{}}, + /* compute_changes= */ false); + + // flagA is dropped and flagB takes the new value. + EXPECT_FALSE(store.Get("flagA")); + EXPECT_EQ(Value("b2"), store.Get("flagB")->item->Detail().Value()); +} + +TEST(FlagStoreApplyTests, PartialChangeSetMergesIntoExistingData) { + FlagStore store; + + store.Apply(FlagChangeSet{ChangeSetType::kFull, + {FlagChange{"flagA", Flag(1, Value("a"))}}, + Selector{}}, + /* compute_changes= */ false); + + // A partial changeset adding flagB. + store.Apply(FlagChangeSet{ChangeSetType::kPartial, + {FlagChange{"flagB", Flag(1, Value("b"))}}, + Selector{}}, + /* compute_changes= */ false); + + // flagA is kept and flagB is added. + EXPECT_EQ(Value("a"), store.Get("flagA")->item->Detail().Value()); + EXPECT_EQ(Value("b"), store.Get("flagB")->item->Detail().Value()); +} + +TEST(FlagStoreApplyTests, DeleteStoresATombstone) { + FlagStore store; + + store.Apply(FlagChangeSet{ChangeSetType::kFull, + {FlagChange{"flagA", Flag(1, Value("a"))}}, + Selector{}}, + /* compute_changes= */ false); + + // A partial changeset deleting flagA. + store.Apply( + FlagChangeSet{ChangeSetType::kPartial, + {FlagChange{"flagA", ItemDescriptor{Tombstone{5}}}}, + Selector{}}, + /* compute_changes= */ false); + + // flagA becomes a tombstone that carries the delete version. + auto descriptor = store.Get("flagA"); + ASSERT_TRUE(descriptor); + EXPECT_FALSE(descriptor->item.has_value()); + EXPECT_EQ(5u, descriptor->version); +} + +// FDv2 reserves the per-flag version for event tracking, so a lower version +// must not cause the apply to reject an update the way FDv1's Upsert does. +TEST(FlagStoreApplyTests, LowerVersionDoesNotRejectTheUpdate) { + FlagStore store; + + store.Apply(FlagChangeSet{ChangeSetType::kFull, + {FlagChange{"flagA", Flag(10, Value("new"))}}, + Selector{}}, + /* compute_changes= */ false); + + // A partial changeset with a lower version than the stored flag. + store.Apply(FlagChangeSet{ChangeSetType::kPartial, + {FlagChange{"flagA", Flag(2, Value("old"))}}, + Selector{}}, + /* compute_changes= */ false); + + // The lower-version update still applies. + EXPECT_EQ(Value("old"), store.Get("flagA")->item->Detail().Value()); +} + +TEST(FlagStoreApplyTests, ChangeSetSelectorBecomesTheCurrentSelector) { + FlagStore store; + + // A fresh store has no selector. + EXPECT_FALSE(store.CurrentSelector().value.has_value()); + + // Apply a changeset carrying a selector. + store.Apply(FlagChangeSet{ChangeSetType::kFull, + {FlagChange{"flagA", Flag(1, Value("a"))}}, + SelectorAt(3, "state-3")}, + /* compute_changes= */ false); + + // The store adopts that selector. + auto selector = store.CurrentSelector(); + ASSERT_TRUE(selector.value.has_value()); + EXPECT_EQ(3, selector.value->version); + EXPECT_EQ("state-3", selector.value->state); +} + +TEST(FlagStoreApplyTests, PayloadWithoutASelectorDiscardsTheCurrentSelector) { + FlagStore store; + + store.Apply(FlagChangeSet{ChangeSetType::kFull, + {FlagChange{"flagA", Flag(1, Value("a"))}}, + SelectorAt(3, "state-3")}, + /* compute_changes= */ false); + + // A partial changeset that carries no selector. + store.Apply(FlagChangeSet{ChangeSetType::kPartial, + {FlagChange{"flagA", Flag(2, Value("a2"))}}, + Selector{}}, + /* compute_changes= */ false); + + // The store discards the selector it had. + EXPECT_FALSE(store.CurrentSelector().value.has_value()); +} + +// Init replaces the data from a source that carries no selector, so a selector +// the store held from an earlier payload no longer describes the data. +TEST(FlagStoreApplyTests, InitDiscardsTheCurrentSelector) { + FlagStore store; + + store.Apply(FlagChangeSet{ChangeSetType::kFull, + {FlagChange{"flagA", Flag(1, Value("a"))}}, + SelectorAt(3, "state-3")}, + /* compute_changes= */ false); + + store.Init(std::unordered_map{ + {"flagA", Flag(2, Value("a2"))}}); + + EXPECT_FALSE(store.CurrentSelector().value.has_value()); +} + +// Upsert applies a single flag from a source that carries no selector, so it +// discards the held selector for the same reason as Init. +TEST(FlagStoreApplyTests, UpsertDiscardsTheCurrentSelector) { + FlagStore store; + + store.Apply(FlagChangeSet{ChangeSetType::kFull, + {FlagChange{"flagA", Flag(1, Value("a"))}}, + SelectorAt(3, "state-3")}, + /* compute_changes= */ false); + + store.Upsert("flagB", Flag(1, Value("b"))); + + EXPECT_FALSE(store.CurrentSelector().value.has_value()); +} + +// A "none" intent is not a payload. It confirms the data is current, so both +// the flag data and the selector it was verified against still stand. +TEST(FlagStoreApplyTests, NoneChangeSetLeavesDataAndSelectorUnchanged) { + FlagStore store; + + store.Apply(FlagChangeSet{ChangeSetType::kFull, + {FlagChange{"flagA", Flag(1, Value("a"))}}, + SelectorAt(3, "state-3")}, + /* compute_changes= */ false); + + // Apply a "none" changeset. + store.Apply(FlagChangeSet{ChangeSetType::kNone, {}, Selector{}}, + /* compute_changes= */ false); + + // The flag data is untouched. + EXPECT_EQ(Value("a"), store.Get("flagA")->item->Detail().Value()); + // The selector still stands. + auto selector = store.CurrentSelector(); + ASSERT_TRUE(selector.value.has_value()); + EXPECT_EQ("state-3", selector.value->state); +} + +TEST(FlagStoreApplyTests, ClearSelectorLeavesFlagDataInPlace) { + FlagStore store; + + store.Apply(FlagChangeSet{ChangeSetType::kFull, + {FlagChange{"flagA", Flag(1, Value("a"))}}, + SelectorAt(3, "state-3")}, + /* compute_changes= */ false); + + // Clear the selector. + store.ClearSelector(); + + // The selector is gone but the flag data remains. + EXPECT_FALSE(store.CurrentSelector().value.has_value()); + EXPECT_EQ(Value("a"), store.Get("flagA")->item->Detail().Value()); +} + +TEST(FlagStoreApplyTests, ReportsNoChangesWhenComputeChangesIsFalse) { + FlagStore store; + + store.Apply(FlagChangeSet{ChangeSetType::kFull, + {FlagChange{"flagA", Flag(1, Value("a"))}}, + Selector{}}, + /* compute_changes= */ false); + + // Change flagA with change computation disabled. + auto events = + store.Apply(FlagChangeSet{ChangeSetType::kPartial, + {FlagChange{"flagA", Flag(2, Value("a2"))}}, + Selector{}}, + /* compute_changes= */ false); + + // No events are produced. + EXPECT_TRUE(events.empty()); +} + +TEST(FlagStoreApplyTests, ReportsAChangedValue) { + FlagStore store; + + store.Apply(FlagChangeSet{ChangeSetType::kFull, + {FlagChange{"flagA", Flag(1, Value("a"))}}, + Selector{}}, + /* compute_changes= */ true); + + // Change flagA's value. + auto events = + store.Apply(FlagChangeSet{ChangeSetType::kPartial, + {FlagChange{"flagA", Flag(2, Value("a2"))}}, + Selector{}}, + /* compute_changes= */ true); + + // One event reports flagA moving from "a" to "a2". + ASSERT_EQ(1u, events.size()); + EXPECT_EQ("flagA", events[0].FlagName()); + EXPECT_EQ(Value("a"), events[0].OldValue()); + EXPECT_EQ(Value("a2"), events[0].NewValue()); + EXPECT_FALSE(events[0].Deleted()); +} + +TEST(FlagStoreApplyTests, ReportsNothingWhenTheValueIsUnchanged) { + FlagStore store; + + store.Apply(FlagChangeSet{ChangeSetType::kFull, + {FlagChange{"flagA", Flag(1, Value("a"))}}, + Selector{}}, + /* compute_changes= */ true); + + // Re-apply flagA with the same value at a new version. + auto events = + store.Apply(FlagChangeSet{ChangeSetType::kPartial, + {FlagChange{"flagA", Flag(2, Value("a"))}}, + Selector{}}, + /* compute_changes= */ true); + + // No event, because the value did not change. + EXPECT_TRUE(events.empty()); +} + +TEST(FlagStoreApplyTests, ReportsANewFlagAgainstANullOldValue) { + FlagStore store; + + store.Apply(FlagChangeSet{ChangeSetType::kFull, + {FlagChange{"flagA", Flag(1, Value("a"))}}, + Selector{}}, + /* compute_changes= */ true); + + // Add a new flag, flagB. + auto events = + store.Apply(FlagChangeSet{ChangeSetType::kPartial, + {FlagChange{"flagB", Flag(1, Value("b"))}}, + Selector{}}, + /* compute_changes= */ true); + + // One event reports flagB appearing against a null old value. + ASSERT_EQ(1u, events.size()); + EXPECT_EQ("flagB", events[0].FlagName()); + EXPECT_TRUE(events[0].OldValue().IsNull()); + EXPECT_EQ(Value("b"), events[0].NewValue()); +} + +// The data the SDK starts from is not a change to what it was evaluating +// before, because it was not evaluating anything. +TEST(FlagStoreApplyTests, ReportsNothingForTheFirstFullChangeSet) { + FlagStore store; + + auto events = + store.Apply(FlagChangeSet{ChangeSetType::kFull, + {FlagChange{"flagA", Flag(1, Value("a"))}}, + Selector{}}, + /* compute_changes= */ true); + + // No events, since this is the starting basis. + EXPECT_TRUE(events.empty()); + // The data is still stored. + EXPECT_EQ(Value("a"), store.Get("flagA")->item->Detail().Value()); +} + +TEST(FlagStoreApplyTests, ReportsADeletedFlag) { + FlagStore store; + + store.Apply(FlagChangeSet{ChangeSetType::kFull, + {FlagChange{"flagA", Flag(1, Value("a"))}}, + Selector{}}, + /* compute_changes= */ true); + + // Delete flagA. + auto events = store.Apply( + FlagChangeSet{ChangeSetType::kPartial, + {FlagChange{"flagA", ItemDescriptor{Tombstone{5}}}}, + Selector{}}, + /* compute_changes= */ true); + + // One event reports flagA deleted, carrying its old value. + ASSERT_EQ(1u, events.size()); + EXPECT_EQ("flagA", events[0].FlagName()); + EXPECT_EQ(Value("a"), events[0].OldValue()); + EXPECT_TRUE(events[0].Deleted()); +} + +TEST(FlagStoreApplyTests, ReportsNothingWhenDeletingAnAbsentFlag) { + FlagStore store; + + auto events = store.Apply( + FlagChangeSet{ChangeSetType::kPartial, + {FlagChange{"flagA", ItemDescriptor{Tombstone{5}}}}, + Selector{}}, + /* compute_changes= */ true); + + // No event, because there was nothing to delete. + EXPECT_TRUE(events.empty()); +} + +TEST(FlagStoreApplyTests, ReportsFlagsAFullChangeSetOmitsAsDeleted) { + FlagStore store; + + store.Apply(FlagChangeSet{ChangeSetType::kFull, + {FlagChange{"flagA", Flag(1, Value("a"))}, + FlagChange{"flagB", Flag(1, Value("b"))}}, + Selector{}}, + /* compute_changes= */ true); + + // A full changeset that omits flagB. + auto events = + store.Apply(FlagChangeSet{ChangeSetType::kFull, + {FlagChange{"flagA", Flag(2, Value("a"))}}, + Selector{}}, + /* compute_changes= */ true); + + // One event reports the omitted flagB deleted. + ASSERT_EQ(1u, events.size()); + EXPECT_EQ("flagB", events[0].FlagName()); + EXPECT_EQ(Value("b"), events[0].OldValue()); + EXPECT_TRUE(events[0].Deleted()); +} + +// A partial changeset only describes the keys it carries, so an absent key is +// untouched rather than deleted. +TEST(FlagStoreApplyTests, ReportsNoDeletionForFlagsAPartialChangeSetOmits) { + FlagStore store; + + store.Apply(FlagChangeSet{ChangeSetType::kFull, + {FlagChange{"flagA", Flag(1, Value("a"))}, + FlagChange{"flagB", Flag(1, Value("b"))}}, + Selector{}}, + /* compute_changes= */ true); + + // A partial changeset that omits flagB. + auto events = + store.Apply(FlagChangeSet{ChangeSetType::kPartial, + {FlagChange{"flagA", Flag(2, Value("a2"))}}, + Selector{}}, + /* compute_changes= */ true); + + // Only flagA is reported as changed. + ASSERT_EQ(1u, events.size()); + EXPECT_EQ("flagA", events[0].FlagName()); + // flagB is left in place. + EXPECT_EQ(Value("b"), store.Get("flagB")->item->Detail().Value()); +} diff --git a/libs/client-sdk/tests/flag_updater_test.cpp b/libs/client-sdk/tests/flag_updater_test.cpp index d9a7515c1..4ffb81b44 100644 --- a/libs/client-sdk/tests/flag_updater_test.cpp +++ b/libs/client-sdk/tests/flag_updater_test.cpp @@ -12,11 +12,15 @@ using launchdarkly::ContextBuilder; using launchdarkly::EvaluationDetailInternal; using launchdarkly::EvaluationResult; using launchdarkly::Value; +using launchdarkly::client_side::FlagChange; +using launchdarkly::client_side::FlagChangeSet; using launchdarkly::client_side::ItemDescriptor; using launchdarkly::client_side::flag_manager::FlagStore; using launchdarkly::client_side::flag_manager::FlagUpdater; using launchdarkly::client_side::flag_manager::FlagValueChangeEvent; using launchdarkly::client_side::flag_manager::IFlagNotifier; +using launchdarkly::data_model::ChangeSetType; +using launchdarkly::data_model::Selector; using Tombstone = launchdarkly::data_model::Tombstone; TEST(FlagUpdaterDataTests, HandlesEmptyInit) { @@ -207,7 +211,8 @@ TEST(FlagUpdaterEventTests, SecondInitWithUpdateProducesEvents) { std::atomic_bool got_event(false); notifier->OnFlagChange( - "flagA", [&got_event, &manager](std::shared_ptr event) { + "flagA", + [&got_event, &manager](std::shared_ptr event) { got_event.store(true); EXPECT_EQ("test", event->OldValue().AsString()); @@ -777,3 +782,80 @@ TEST(FlagUpdaterEventTests, CanListenToMultipleFlags) { EXPECT_TRUE(got_event_a); EXPECT_TRUE(got_event_b); } + +TEST(FlagUpdaterApplyTests, ApplyDispatchesValueChangeEvents) { + FlagStore manager; + FlagUpdater updater(manager); + + IFlagNotifier* notifier = &updater; + + std::atomic_bool got_event(false); + auto connection = notifier->OnFlagChange( + "flagA", + [&got_event, &manager](std::shared_ptr event) { + got_event.store(true); + + EXPECT_EQ("test", event->OldValue().AsString()); + EXPECT_EQ("potato", event->NewValue().AsString()); + EXPECT_EQ("flagA", event->FlagName()); + EXPECT_FALSE(event->Deleted()); + + // The value in the store should be consistent with the new value. + EXPECT_EQ("potato", + manager.Get("flagA")->item->Detail().Value().AsString()); + }); + + // Establish the basis with a first full changeset. + updater.Apply( + ContextBuilder().Kind("user", "user-key").Build(), + FlagChangeSet{ + ChangeSetType::kFull, + {FlagChange{"flagA", + ItemDescriptor{EvaluationResult{ + 1, std::nullopt, false, false, std::nullopt, + EvaluationDetailInternal{ + Value("test"), std::nullopt, std::nullopt}}}}}, + Selector{}}, + /* from_cache= */ false); + + // The first full data set is what the SDK starts from, not a change. + EXPECT_FALSE(got_event); + + // Change flagA's value. + updater.Apply( + ContextBuilder().Kind("user", "user-key").Build(), + FlagChangeSet{ + ChangeSetType::kPartial, + {FlagChange{ + "flagA", + ItemDescriptor{EvaluationResult{ + 2, std::nullopt, false, false, std::nullopt, + EvaluationDetailInternal{Value("potato"), std::nullopt, + std::nullopt}}}}}, + Selector{}}, + /* from_cache= */ false); + + // The listener received the change event. + EXPECT_TRUE(got_event); +} + +TEST(FlagUpdaterApplyTests, ApplyOfNoneChangeSetDispatchesNothing) { + FlagStore manager; + FlagUpdater updater(manager); + + IFlagNotifier* notifier = &updater; + + std::atomic_bool got_event(false); + auto connection = notifier->OnFlagChange( + "flagA", [&got_event](std::shared_ptr event) { + got_event.store(true); + }); + + // Apply a "none" changeset. + updater.Apply(ContextBuilder().Kind("user", "user-key").Build(), + FlagChangeSet{ChangeSetType::kNone, {}, Selector{}}, + /* from_cache= */ false); + + // No change event is dispatched. + EXPECT_FALSE(got_event); +} diff --git a/libs/server-sdk/src/data_systems/fdv2/conditions.hpp b/libs/internal/include/launchdarkly/data_sources/fdv2/conditions.hpp similarity index 70% rename from libs/server-sdk/src/data_systems/fdv2/conditions.hpp rename to libs/internal/include/launchdarkly/data_sources/fdv2/conditions.hpp index 70a3518f7..56a3eeea1 100644 --- a/libs/server-sdk/src/data_systems/fdv2/conditions.hpp +++ b/libs/internal/include/launchdarkly/data_sources/fdv2/conditions.hpp @@ -1,6 +1,6 @@ #pragma once -#include "../../data_interfaces/source/ifdv2_condition.hpp" +#include #include #include @@ -14,7 +14,7 @@ #include #include -namespace launchdarkly::server_side::data_systems { +namespace launchdarkly::internal::data_sources { /** * Base class for conditions that fire after a duration elapses on the @@ -25,8 +25,12 @@ namespace launchdarkly::server_side::data_systems { * Derived classes implement Inform() to translate orchestrator events into * arm/cancel actions on the timer. Subclasses also implement GetType() to * report whether they are a fallback or recovery condition. + * + * Thread-safe: every method may be called from any thread. The timer state is + * held behind a mutex in a shared State, so a timer callback firing on the + * executor is safe against a concurrent Close() from a caller's thread. */ -class TimedCondition : public data_interfaces::IFDv2Condition { +class TimedCondition : public IFDv2Condition { public: TimedCondition(boost::asio::any_io_executor executor, std::chrono::milliseconds timeout); @@ -54,6 +58,9 @@ class TimedCondition : public data_interfaces::IFDv2Condition { private: struct State { std::mutex mutex; + // All protected by mutex. timer_cancel is replaced when the timer is + // re-armed, so the lock covers the replacement and not just the + // source's own operations. bool closed = false; async::Promise promise; std::optional timer_cancel; @@ -68,13 +75,15 @@ class TimedCondition : public data_interfaces::IFDv2Condition { * Fires after the active synchronizer has been continuously interrupted for * the configured timeout. Each CHANGE_SET result cancels any pending timer; * the next Interrupted status re-arms it. + * + * Thread-safe, as TimedCondition is. */ class FallbackCondition final : public TimedCondition { public: FallbackCondition(boost::asio::any_io_executor executor, std::chrono::milliseconds timeout); - void Inform(data_interfaces::FDv2SourceResult const& result) override; + void Inform(SourceSignal signal) override; [[nodiscard]] Type GetType() const override { return Type::kFallback; } }; @@ -83,31 +92,33 @@ class FallbackCondition final : public TimedCondition { * Fires after the active synchronizer has been running for the configured * timeout, regardless of result content. The timer is started at * construction; Inform() is a no-op. + * + * Thread-safe, as TimedCondition is. */ class RecoveryCondition final : public TimedCondition { public: RecoveryCondition(boost::asio::any_io_executor executor, std::chrono::milliseconds timeout); - void Inform(data_interfaces::FDv2SourceResult const& result) override; + void Inform(SourceSignal signal) override; [[nodiscard]] Type GetType() const override { return Type::kRecovery; } }; /** * Builds fresh FallbackCondition instances on demand. + * + * Thread-safe: Build() and GetType() may be called from any thread, and + * hold no state beyond the executor and timeout given at construction. */ -class FallbackConditionFactory final - : public data_interfaces::IFDv2ConditionFactory { +class FallbackConditionFactory final : public IFDv2ConditionFactory { public: FallbackConditionFactory(boost::asio::any_io_executor executor, std::chrono::milliseconds timeout); - [[nodiscard]] std::unique_ptr Build() - override; + [[nodiscard]] std::unique_ptr Build() override; - [[nodiscard]] data_interfaces::IFDv2Condition::Type GetType() - const override; + [[nodiscard]] IFDv2Condition::Type GetType() const override; private: boost::asio::any_io_executor const executor_; @@ -116,18 +127,18 @@ class FallbackConditionFactory final /** * Builds fresh RecoveryCondition instances on demand. + * + * Thread-safe: Build() and GetType() may be called from any thread, and + * hold no state beyond the executor and timeout given at construction. */ -class RecoveryConditionFactory final - : public data_interfaces::IFDv2ConditionFactory { +class RecoveryConditionFactory final : public IFDv2ConditionFactory { public: RecoveryConditionFactory(boost::asio::any_io_executor executor, std::chrono::milliseconds timeout); - [[nodiscard]] std::unique_ptr Build() - override; + [[nodiscard]] std::unique_ptr Build() override; - [[nodiscard]] data_interfaces::IFDv2Condition::Type GetType() - const override; + [[nodiscard]] IFDv2Condition::Type GetType() const override; private: boost::asio::any_io_executor const executor_; @@ -145,8 +156,7 @@ class RecoveryConditionFactory final class Conditions final { public: explicit Conditions( - std::vector> - conditions); + std::vector> conditions); ~Conditions(); @@ -161,29 +171,30 @@ class Conditions final { * `token` once the result is no longer needed, so that the per-call * Promise (and its registered continuations) can be released. */ - [[nodiscard]] async::Future - GetFuture(async::CancellationToken token); + [[nodiscard]] async::Future GetFuture( + async::CancellationToken token); - void Inform(data_interfaces::FDv2SourceResult const& result); + void Inform(SourceSignal signal); void Close(); private: struct PendingEntry { std::int64_t id; - async::Promise promise; + async::Promise promise; std::unique_ptr cancel_cb; }; struct State { std::mutex mutex; + // All protected by mutex. std::int64_t next_id = 0; std::vector pending; - std::optional aggregate_result; + std::optional aggregate_result; }; - std::vector> conditions_; + std::vector> conditions_; std::shared_ptr const state_; }; -} // namespace launchdarkly::server_side::data_systems +} // namespace launchdarkly::internal::data_sources diff --git a/libs/server-sdk/src/data_interfaces/source/ifdv2_condition.hpp b/libs/internal/include/launchdarkly/data_sources/fdv2/ifdv2_condition.hpp similarity index 82% rename from libs/server-sdk/src/data_interfaces/source/ifdv2_condition.hpp rename to libs/internal/include/launchdarkly/data_sources/fdv2/ifdv2_condition.hpp index ea19f0520..be19a7d01 100644 --- a/libs/server-sdk/src/data_interfaces/source/ifdv2_condition.hpp +++ b/libs/internal/include/launchdarkly/data_sources/fdv2/ifdv2_condition.hpp @@ -1,15 +1,27 @@ #pragma once -#include "fdv2_source_result.hpp" - #include #include -namespace launchdarkly::server_side::data_interfaces { +namespace launchdarkly::internal::data_sources { + +/** + * What the orchestrator observed from the active synchronizer, reduced to the + * distinctions a condition acts on. The orchestrator maps its own result type + * onto this before informing its conditions. + */ +enum class SourceSignal { + /** A changeset arrived. */ + kChangeSet, + /** The synchronizer reported a recoverable failure. */ + kInterrupted, + /** Anything else, which no condition acts on. */ + kOther, +}; /** - * A condition observes the orchestrator's stream of synchronizer results and + * A condition observes the orchestrator's stream of synchronizer signals and * fires when criteria for a synchronizer transition are met. * * Each condition plays one of two roles, identified by Type(): @@ -18,7 +30,7 @@ namespace launchdarkly::server_side::data_interfaces { * - kRecovery: when fired, the orchestrator stops the active fallback * synchronizer and returns to the most-preferred synchronizer. * - * Conditions are stateful: the orchestrator pushes results into a condition + * Conditions are stateful: the orchestrator pushes signals into a condition * via Inform() so the condition can update its internal state (typically a * timer). When the condition's criteria are satisfied, the future returned * by Execute() resolves with the condition's Type. @@ -52,10 +64,10 @@ class IFDv2Condition { [[nodiscard]] virtual async::Future Execute() = 0; /** - * Pushes a synchronizer result into the condition so it can update any + * Pushes a synchronizer signal into the condition so it can update any * internal state (e.g., arm or cancel a timer). */ - virtual void Inform(FDv2SourceResult const& result) = 0; + virtual void Inform(SourceSignal signal) = 0; /** * Cancels any pending internal work and resolves the future returned by @@ -104,4 +116,4 @@ class IFDv2ConditionFactory { IFDv2ConditionFactory() = default; }; -} // namespace launchdarkly::server_side::data_interfaces +} // namespace launchdarkly::internal::data_sources diff --git a/libs/server-sdk/src/data_systems/fdv2/source_manager.hpp b/libs/internal/include/launchdarkly/data_sources/fdv2/source_manager.hpp similarity index 52% rename from libs/server-sdk/src/data_systems/fdv2/source_manager.hpp rename to libs/internal/include/launchdarkly/data_sources/fdv2/source_manager.hpp index 264dfd494..5d6d1b42b 100644 --- a/libs/server-sdk/src/data_systems/fdv2/source_manager.hpp +++ b/libs/internal/include/launchdarkly/data_sources/fdv2/source_manager.hpp @@ -1,13 +1,11 @@ #pragma once -#include "../../data_interfaces/source/ifdv2_synchronizer.hpp" -#include "../../data_interfaces/source/ifdv2_synchronizer_factory.hpp" - #include #include +#include #include -namespace launchdarkly::server_side::data_systems { +namespace launchdarkly::internal::data_sources { /** * Manages a list of synchronizer factories together with per-factory state @@ -26,12 +24,25 @@ namespace launchdarkly::server_side::data_systems { * Factories whose IsFDv1Fallback() returns true start in the Blocked state. * * Not thread-safe. The caller is responsible for serializing all calls. + * + * @tparam Factory The SDK's synchronizer factory interface, which must supply + * IsFDv1Fallback() and a Build() returning a smart pointer to a synchronizer. */ +template class SourceManager { public: - explicit SourceManager( - std::vector> - factories); + using SynchronizerPtr = decltype(std::declval().Build()); + + explicit SourceManager(std::vector> factories) { + synchronizers_.reserve(factories.size()); + for (auto& factory : factories) { + bool const is_fdv1_fallback = factory->IsFDv1Fallback(); + synchronizers_.push_back(SynchronizerFactoryWithState{ + std::move(factory), + is_fdv1_fallback ? State::kBlocked : State::kAvailable, + is_fdv1_fallback}); + } + } /** * Advances to the next Available synchronizer factory (wrapping past the @@ -39,19 +50,40 @@ class SourceManager { * as the current one for subsequent queries. Returns nullptr if no * Available factory exists. */ - std::unique_ptr NextSynchronizer(); + SynchronizerPtr NextSynchronizer() { + if (synchronizers_.empty()) { + current_factory_index_ = -1; + return nullptr; + } + for (std::size_t visited = 0; visited < synchronizers_.size(); + ++visited) { + synchronizer_index_ = (synchronizer_index_ + 1) % + static_cast(synchronizers_.size()); + if (synchronizers_[synchronizer_index_].state == + State::kAvailable) { + current_factory_index_ = synchronizer_index_; + return synchronizers_[synchronizer_index_].factory->Build(); + } + } + current_factory_index_ = -1; + return nullptr; + } /** * Marks the currently tracked factory as Blocked. No-op if no factory is * currently tracked. */ - void BlockCurrentSynchronizer(); + void BlockCurrentSynchronizer() { + if (current_factory_index_ >= 0) { + synchronizers_[current_factory_index_].state = State::kBlocked; + } + } /** * Resets the iteration cursor so that the next call to NextSynchronizer * begins searching from index 0. */ - void ResetSourceIndex(); + void ResetSourceIndex() { synchronizer_index_ = -1; } /** * Blocks every non-FDv1 factory and unblocks the FDv1 fallback factory, @@ -59,37 +91,69 @@ class SourceManager { * NextSynchronizer returns the FDv1 fallback. If no FDv1 fallback factory * was configured, every factory is left blocked. */ - void SwitchToFDv1Fallback(); + void SwitchToFDv1Fallback() { + for (auto& entry : synchronizers_) { + entry.state = + entry.is_fdv1_fallback ? State::kAvailable : State::kBlocked; + } + synchronizer_index_ = -1; + } /** * Returns synchronizer state to the initial configuration, including * unblocking factories previously blocked by terminal errors. */ - void SwitchBackToFDv2(); + void SwitchBackToFDv2() { + for (auto& entry : synchronizers_) { + entry.state = + entry.is_fdv1_fallback ? State::kBlocked : State::kAvailable; + } + synchronizer_index_ = -1; + } /** * Returns true if the currently tracked factory is the first Available * factory in the list. Returns false if no factory is currently tracked. */ - [[nodiscard]] bool IsPrimeSynchronizer() const; + [[nodiscard]] bool IsPrimeSynchronizer() const { + for (std::size_t i = 0; i < synchronizers_.size(); ++i) { + if (synchronizers_[i].state == State::kAvailable) { + return synchronizer_index_ == static_cast(i); + } + } + return false; + } /** * Returns the count of factories not in the Blocked state. */ - [[nodiscard]] std::size_t AvailableSynchronizerCount() const; + [[nodiscard]] std::size_t AvailableSynchronizerCount() const { + std::size_t count = 0; + for (auto const& s : synchronizers_) { + if (s.state == State::kAvailable) { + ++count; + } + } + return count; + } /** * Returns the total number of factories configured at construction * (including any currently in the Blocked state). Constant for the * lifetime of the SourceManager. */ - [[nodiscard]] std::size_t SynchronizerCount() const; + [[nodiscard]] std::size_t SynchronizerCount() const { + return synchronizers_.size(); + } /** * Returns true if the currently tracked factory is the FDv1 fallback * synchronizer. */ - [[nodiscard]] bool IsCurrentSynchronizerFDv1Fallback() const; + [[nodiscard]] bool IsCurrentSynchronizerFDv1Fallback() const { + return current_factory_index_ >= 0 && + synchronizers_[current_factory_index_].is_fdv1_fallback; + } SourceManager(SourceManager const&) = delete; SourceManager(SourceManager&&) = delete; @@ -101,7 +165,7 @@ class SourceManager { enum class State { kAvailable, kBlocked }; struct SynchronizerFactoryWithState { - std::unique_ptr factory; + std::unique_ptr factory; State state = State::kAvailable; bool is_fdv1_fallback = false; }; @@ -113,4 +177,4 @@ class SourceManager { int current_factory_index_ = -1; }; -} // namespace launchdarkly::server_side::data_systems +} // namespace launchdarkly::internal::data_sources diff --git a/libs/internal/include/launchdarkly/network/asio_requester.hpp b/libs/internal/include/launchdarkly/network/asio_requester.hpp index cf0927417..80821c1f6 100644 --- a/libs/internal/include/launchdarkly/network/asio_requester.hpp +++ b/libs/internal/include/launchdarkly/network/asio_requester.hpp @@ -284,13 +284,13 @@ class AsioRequester { template auto Request(HttpRequest request, CompletionToken&& token) const { return boost::asio::async_initiate( - [this](auto handler, HttpRequest req) { + [ctx = ctx_, ssl_ctx = ssl_ctx_](auto handler, HttpRequest req) { InnerRequest( - net::make_strand(ctx_), std::move(req), + net::make_strand(ctx), std::move(req), [h = std::move(handler)](HttpResult result) mutable { std::move(h)(std::move(result)); }, - 0); + 0, ssl_ctx); }, token, std::move(request)); } @@ -304,10 +304,11 @@ class AsioRequester { */ std::shared_ptr ssl_ctx_; - void InnerRequest(boost::asio::any_io_executor exec, - std::optional request, - std::function callback, - unsigned char redirect_count) const { + static void InnerRequest(boost::asio::any_io_executor exec, + std::optional request, + std::function callback, + unsigned char redirect_count, + std::shared_ptr ssl_ctx) { if (redirect_count > kRedirectLimit) { boost::asio::post(exec, [callback, request]() mutable { callback( @@ -326,8 +327,8 @@ class AsioRequester { return; } - boost::asio::post(exec, [exec, callback, request, this, - redirect_count]() mutable { + boost::asio::post(exec, [exec, callback, request, redirect_count, + ssl_ctx]() mutable { auto beast_request = MakeBeastRequest(*request); auto const& properties = request->Properties(); @@ -337,16 +338,16 @@ class AsioRequester { std::shared_ptr ssl; if (request->Https()) { - ssl = this->ssl_ctx_; + ssl = ssl_ctx; } std::make_shared( exec, std::move(ssl), request->Host(), service, beast_request, properties.ConnectTimeout(), properties.ResponseTimeout(), - [exec, callback, request, this, redirect_count](auto res) { + [exec, callback, request, redirect_count, ssl_ctx](auto res) { NeedsRedirect(res) ? InnerRequest(exec, MakeRedirectRequest(*request, res), - callback, redirect_count + 1) + callback, redirect_count + 1, ssl_ctx) : callback(res); }) ->Run(); diff --git a/libs/internal/include/launchdarkly/serialization/json_evaluation_result.hpp b/libs/internal/include/launchdarkly/serialization/json_evaluation_result.hpp index b88912073..6091b879c 100644 --- a/libs/internal/include/launchdarkly/serialization/json_evaluation_result.hpp +++ b/libs/internal/include/launchdarkly/serialization/json_evaluation_result.hpp @@ -6,8 +6,23 @@ #include #include +#include +#include + namespace launchdarkly { +/** + * Deserializes an evaluation result. + * + * @param json_value The object to deserialize. A null value yields + * std::nullopt rather than an error. + * @param version_override The version to assign to the result. Pass + * std::nullopt to read the version from the object itself. + */ +tl::expected, JsonError> ParseEvaluationResult( + boost::json::value const& json_value, + std::optional version_override); + tl::expected, JsonError> tag_invoke( boost::json::value_to_tag< tl::expected, JsonError>> const& unused, diff --git a/libs/internal/src/CMakeLists.txt b/libs/internal/src/CMakeLists.txt index 1b3d54e3c..40ccbded2 100644 --- a/libs/internal/src/CMakeLists.txt +++ b/libs/internal/src/CMakeLists.txt @@ -8,12 +8,14 @@ file(GLOB HEADER_LIST CONFIGURE_DEPENDS "${LaunchDarklyInternalSdk_SOURCE_DIR}/include/launchdarkly/serialization/events/*.hpp" "${LaunchDarklyInternalSdk_SOURCE_DIR}/include/launchdarkly/signals/*.hpp" "${LaunchDarklyInternalSdk_SOURCE_DIR}/include/launchdarkly/data_sources/*.hpp" + "${LaunchDarklyInternalSdk_SOURCE_DIR}/include/launchdarkly/data_sources/fdv2/*.hpp" ) # Automatic library: static or dynamic based on user config. set(INTERNAL_SOURCES ${HEADER_LIST} context_filter.cpp + data_sources/fdv2/conditions.cpp events/asio_event_processor.cpp events/null_event_processor.cpp events/common_events.cpp diff --git a/libs/server-sdk/src/data_systems/fdv2/conditions.cpp b/libs/internal/src/data_sources/fdv2/conditions.cpp similarity index 92% rename from libs/server-sdk/src/data_systems/fdv2/conditions.cpp rename to libs/internal/src/data_sources/fdv2/conditions.cpp index 01350eb93..3c9e9a09b 100644 --- a/libs/server-sdk/src/data_systems/fdv2/conditions.cpp +++ b/libs/internal/src/data_sources/fdv2/conditions.cpp @@ -1,15 +1,11 @@ -#include "conditions.hpp" +#include #include #include #include -#include -namespace launchdarkly::server_side::data_systems { - -using data_interfaces::FDv2SourceResult; -using data_interfaces::IFDv2Condition; +namespace launchdarkly::internal::data_sources { TimedCondition::TimedCondition(boost::asio::any_io_executor executor, std::chrono::milliseconds timeout) @@ -73,10 +69,10 @@ FallbackCondition::FallbackCondition(boost::asio::any_io_executor executor, std::chrono::milliseconds timeout) : TimedCondition(std::move(executor), timeout) {} -void FallbackCondition::Inform(FDv2SourceResult const& result) { - if (std::get_if(&result.value)) { +void FallbackCondition::Inform(SourceSignal signal) { + if (signal == SourceSignal::kChangeSet) { CancelTimer(); - } else if (std::get_if(&result.value)) { + } else if (signal == SourceSignal::kInterrupted) { ArmTimer(); } } @@ -87,7 +83,7 @@ RecoveryCondition::RecoveryCondition(boost::asio::any_io_executor executor, ArmTimer(); } -void RecoveryCondition::Inform(FDv2SourceResult const&) {} +void RecoveryCondition::Inform(SourceSignal) {} FallbackConditionFactory::FallbackConditionFactory( boost::asio::any_io_executor executor, @@ -211,9 +207,9 @@ async::Future Conditions::GetFuture( return future; } -void Conditions::Inform(FDv2SourceResult const& result) { +void Conditions::Inform(SourceSignal signal) { for (auto const& condition : conditions_) { - condition->Inform(result); + condition->Inform(signal); } } @@ -235,4 +231,4 @@ void Conditions::Close() { } } -} // namespace launchdarkly::server_side::data_systems +} // namespace launchdarkly::internal::data_sources diff --git a/libs/internal/src/serialization/json_evaluation_result.cpp b/libs/internal/src/serialization/json_evaluation_result.cpp index d1429e5fd..b8f70c75a 100644 --- a/libs/internal/src/serialization/json_evaluation_result.cpp +++ b/libs/internal/src/serialization/json_evaluation_result.cpp @@ -8,12 +8,9 @@ #include namespace launchdarkly { -tl::expected, JsonError> tag_invoke( - boost::json::value_to_tag< - tl::expected, JsonError>> const& unused, - boost::json::value const& json_value) { - boost::ignore_unused(unused); - +tl::expected, JsonError> ParseEvaluationResult( + boost::json::value const& json_value, + std::optional version_override) { if (json_value.is_null()) { return std::nullopt; } @@ -23,7 +20,9 @@ tl::expected, JsonError> tag_invoke( auto const& json_obj = json_value.as_object(); auto* version_iter = json_obj.find("version"); - auto version_opt = ValueAsOpt(version_iter, json_obj.end()); + auto version_opt = version_override.has_value() + ? version_override + : ValueAsOpt(version_iter, json_obj.end()); if (!version_opt.has_value()) { return tl::unexpected(JsonError::kSchemaFailure); } @@ -96,7 +95,8 @@ tl::expected, JsonError> tag_invoke( track_reason, debug_events_until_date, EvaluationDetailInternal(std::move(value), variation, - std::make_optional(reason.value()))}; + std::make_optional(reason.value())), + prerequisites}; } // We could not parse the reason. return tl::unexpected(JsonError::kSchemaFailure); @@ -113,6 +113,14 @@ tl::expected, JsonError> tag_invoke( prerequisites}; } +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + return ParseEvaluationResult(json_value, std::nullopt); +} + void tag_invoke(boost::json::value_from_tag const& unused, boost::json::value& json_value, EvaluationResult const& evaluation_result) { diff --git a/libs/server-sdk/tests/conditions_test.cpp b/libs/internal/tests/conditions_test.cpp similarity index 76% rename from libs/server-sdk/tests/conditions_test.cpp rename to libs/internal/tests/conditions_test.cpp index 487833cb7..2466635d1 100644 --- a/libs/server-sdk/tests/conditions_test.cpp +++ b/libs/internal/tests/conditions_test.cpp @@ -1,6 +1,6 @@ #include -#include +#include #include #include @@ -8,8 +8,7 @@ #include #include -using namespace launchdarkly::server_side::data_interfaces; -using namespace launchdarkly::server_side::data_systems; +using namespace launchdarkly::internal::data_sources; using namespace std::chrono_literals; using launchdarkly::async::CancellationToken; @@ -52,11 +51,7 @@ TEST(FallbackConditionTest, InterruptedArmsTimerWhichFiresAfterTimeout) { FallbackCondition condition(ioc.GetExecutor(), /*timeout=*/100ms); auto future = condition.Execute(); - condition.Inform(FDv2SourceResult{FDv2SourceResult::Interrupted{ - FDv2SourceResult::ErrorInfo{ - FDv2SourceResult::ErrorInfo::ErrorKind::kNetworkError, - /*status_code=*/0, "boom", std::chrono::system_clock::now()}, - }}); + condition.Inform(SourceSignal::kInterrupted); auto result = future.WaitForResult(1s); @@ -71,18 +66,8 @@ TEST(FallbackConditionTest, ChangeSetCancelsActiveTimer) { // Arm the timer with Interrupted, then cancel via ChangeSet before it // fires. - condition.Inform(FDv2SourceResult{FDv2SourceResult::Interrupted{ - FDv2SourceResult::ErrorInfo{ - FDv2SourceResult::ErrorInfo::ErrorKind::kNetworkError, - /*status_code=*/0, "boom", std::chrono::system_clock::now()}, - }}); - condition.Inform(FDv2SourceResult{FDv2SourceResult::ChangeSet{ - launchdarkly::data_model::ChangeSet{ - launchdarkly::data_model::ChangeSetType::kFull, - {}, - launchdarkly::data_model::Selector{}, - }, - }}); + condition.Inform(SourceSignal::kInterrupted); + condition.Inform(SourceSignal::kChangeSet); // Wait well past the 100ms threshold; future should remain unresolved. std::this_thread::sleep_for(300ms); @@ -94,11 +79,7 @@ TEST(FallbackConditionTest, CloseCancelsActiveTimerAndResolvesWithCancelled) { FallbackCondition condition(ioc.GetExecutor(), /*timeout=*/100ms); auto future = condition.Execute(); - condition.Inform(FDv2SourceResult{FDv2SourceResult::Interrupted{ - FDv2SourceResult::ErrorInfo{ - FDv2SourceResult::ErrorInfo::ErrorKind::kNetworkError, - /*status_code=*/0, "boom", std::chrono::system_clock::now()}, - }}); + condition.Inform(SourceSignal::kInterrupted); condition.Close(); auto result = future.WaitForResult(200ms); @@ -127,18 +108,8 @@ TEST(RecoveryConditionTest, InformDoesNotAffectTimer) { // Recovery is purely time-based; results from the synchronizer should not // disturb the timer in either direction. - condition.Inform(FDv2SourceResult{FDv2SourceResult::Interrupted{ - FDv2SourceResult::ErrorInfo{ - FDv2SourceResult::ErrorInfo::ErrorKind::kNetworkError, - /*status_code=*/0, "boom", std::chrono::system_clock::now()}, - }}); - condition.Inform(FDv2SourceResult{FDv2SourceResult::ChangeSet{ - launchdarkly::data_model::ChangeSet{ - launchdarkly::data_model::ChangeSetType::kFull, - {}, - launchdarkly::data_model::Selector{}, - }, - }}); + condition.Inform(SourceSignal::kInterrupted); + condition.Inform(SourceSignal::kChangeSet); auto result = future.WaitForResult(1s); ASSERT_TRUE(result.has_value()); @@ -198,11 +169,7 @@ TEST(ConditionsTest, InformForwardsToAllUnderlyingConditions) { std::make_unique(ioc.GetExecutor(), /*timeout=*/1s)); Conditions conditions(std::move(conds)); - conditions.Inform(FDv2SourceResult{FDv2SourceResult::Interrupted{ - FDv2SourceResult::ErrorInfo{ - FDv2SourceResult::ErrorInfo::ErrorKind::kNetworkError, - /*status_code=*/0, "boom", std::chrono::system_clock::now()}, - }}); + conditions.Inform(SourceSignal::kInterrupted); auto result = conditions.GetFuture(CancellationToken{}).WaitForResult(1s); diff --git a/libs/internal/tests/evaluation_result_test.cpp b/libs/internal/tests/evaluation_result_test.cpp index 3d6727295..4960101aa 100644 --- a/libs/internal/tests/evaluation_result_test.cpp +++ b/libs/internal/tests/evaluation_result_test.cpp @@ -61,6 +61,25 @@ TEST(EvaluationResultTests, FromJsonAllFields) { EXPECT_TRUE(val->Detail().Reason()->get().InExperiment()); } +TEST(EvaluationResultTests, PrerequisitesSurviveAlongsideReason) { + // Guards the reason path against dropping top-level prerequisites. + auto evaluation_result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse("{" + "\"version\": 12," + "\"value\": true," + "\"prerequisites\": [\"prereqA\", \"prereqB\"]," + "\"reason\": {\"kind\":\"OFF\"}" + "}")); + + auto const& val = evaluation_result.value(); + ASSERT_TRUE(val->Prerequisites().has_value()); + EXPECT_EQ((std::vector{"prereqA", "prereqB"}), + *val->Prerequisites()); + EXPECT_EQ(EvaluationReason::Kind::kOff, + val->Detail().Reason()->get().Kind()); +} + TEST(EvaluationResultTests, ToJsonAllFields) { EvaluationReason reason(EvaluationReason::Kind::kOff, EvaluationReason::ErrorKind::kMalformedFlag, 12, diff --git a/libs/server-sdk/tests/source_manager_test.cpp b/libs/internal/tests/source_manager_test.cpp similarity index 83% rename from libs/server-sdk/tests/source_manager_test.cpp rename to libs/internal/tests/source_manager_test.cpp index 68bfd5cc2..a6af67e20 100644 --- a/libs/server-sdk/tests/source_manager_test.cpp +++ b/libs/internal/tests/source_manager_test.cpp @@ -1,41 +1,31 @@ #include -#include -#include -#include +#include #include #include #include #include -using namespace launchdarkly::server_side::data_interfaces; -using namespace launchdarkly::server_side::data_systems; - namespace { // Stub synchronizer; SourceManager only cares that Build() returns one. -class StubSynchronizer : public IFDv2Synchronizer { - public: - launchdarkly::async::Future Next( - launchdarkly::data_model::Selector) override { - return launchdarkly::async::MakeFuture( - FDv2SourceResult{FDv2SourceResult::Shutdown{}}); - } - - void Close() override {} +class StubSynchronizer {}; - std::string const& Identity() const override { - static std::string const id = "stub"; - return id; - } +// Stands in for an SDK's synchronizer factory interface, which is all +// SourceManager requires of its type parameter. +class StubFactory { + public: + virtual std::unique_ptr Build() = 0; + [[nodiscard]] virtual bool IsFDv1Fallback() const { return false; } + virtual ~StubFactory() = default; }; // Counts Build() calls for assertion. Tests don't run the returned // synchronizer, so a fresh stub each time is fine. -class CountingFactory : public IFDv2SynchronizerFactory { +class CountingFactory : public StubFactory { public: - std::unique_ptr Build() override { + std::unique_ptr Build() override { ++build_count; return std::make_unique(); } @@ -50,6 +40,9 @@ class FDv1FallbackFactory : public CountingFactory { } // namespace +using SourceManager = + launchdarkly::internal::data_sources::SourceManager; + TEST(SourceManagerTest, EmptyManagerReportsZeroAvailable) { SourceManager mgr({}); @@ -64,7 +57,7 @@ TEST(SourceManagerTest, NextSynchronizerReturnsFirstThenWrapsAround) { auto f1 = std::make_unique(); auto* f0_ptr = f0.get(); auto* f1_ptr = f1.get(); - std::vector> factories; + std::vector> factories; factories.push_back(std::move(f0)); factories.push_back(std::move(f1)); SourceManager mgr(std::move(factories)); @@ -88,7 +81,7 @@ TEST(SourceManagerTest, BlockCurrentSynchronizerRemovesItFromRotation) { auto* f0_ptr = f0.get(); auto* f1_ptr = f1.get(); auto* f2_ptr = f2.get(); - std::vector> factories; + std::vector> factories; factories.push_back(std::move(f0)); factories.push_back(std::move(f1)); factories.push_back(std::move(f2)); @@ -117,7 +110,7 @@ TEST(SourceManagerTest, BlockCurrentSynchronizerRemovesItFromRotation) { TEST(SourceManagerTest, AllBlockedReturnsNullAndZeroCount) { auto f0 = std::make_unique(); auto f1 = std::make_unique(); - std::vector> factories; + std::vector> factories; factories.push_back(std::move(f0)); factories.push_back(std::move(f1)); SourceManager mgr(std::move(factories)); @@ -138,7 +131,7 @@ TEST(SourceManagerTest, ResetSourceIndexSendsNextCallToTheFirstAvailable) { auto f2 = std::make_unique(); auto* f0_ptr = f0.get(); auto* f2_ptr = f2.get(); - std::vector> factories; + std::vector> factories; factories.push_back(std::move(f0)); factories.push_back(std::move(f1)); factories.push_back(std::move(f2)); @@ -162,7 +155,7 @@ TEST(SourceManagerTest, ResetSourceIndexSkipsBlockedFirstFactory) { auto f1 = std::make_unique(); auto* f0_ptr = f0.get(); auto* f1_ptr = f1.get(); - std::vector> factories; + std::vector> factories; factories.push_back(std::move(f0)); factories.push_back(std::move(f1)); SourceManager mgr(std::move(factories)); @@ -183,7 +176,7 @@ TEST(SourceManagerTest, ResetSourceIndexSkipsBlockedFirstFactory) { TEST(SourceManagerTest, IsCurrentSynchronizerFDv1FallbackFalseForFDv2Factory) { auto f0 = std::make_unique(); - std::vector> factories; + std::vector> factories; factories.push_back(std::move(f0)); SourceManager mgr(std::move(factories)); @@ -195,7 +188,7 @@ TEST(SourceManagerTest, FDv1FallbackFactoryStartsBlockedAndIsSkipped) { auto fdv2 = std::make_unique(); auto fdv1 = std::make_unique(); auto* fdv1_ptr = fdv1.get(); - std::vector> factories; + std::vector> factories; factories.push_back(std::move(fdv2)); factories.push_back(std::move(fdv1)); SourceManager mgr(std::move(factories)); @@ -210,7 +203,7 @@ TEST(SourceManagerTest, SwitchToFDv1FallbackBlocksFDv2AndUnblocksFDv1) { auto fdv2 = std::make_unique(); auto fdv1 = std::make_unique(); auto* fdv1_ptr = fdv1.get(); - std::vector> factories; + std::vector> factories; factories.push_back(std::move(fdv2)); factories.push_back(std::move(fdv1)); SourceManager mgr(std::move(factories)); @@ -226,7 +219,7 @@ TEST(SourceManagerTest, SwitchToFDv1FallbackBlocksFDv2AndUnblocksFDv1) { TEST(SourceManagerTest, SwitchToFDv1FallbackWithoutAdapterBlocksEverything) { auto fdv2 = std::make_unique(); - std::vector> factories; + std::vector> factories; factories.push_back(std::move(fdv2)); SourceManager mgr(std::move(factories)); @@ -240,7 +233,7 @@ TEST(SourceManagerTest, SwitchToFDv1FallbackUnblocksPreviouslyBlockedFDv2) { auto fdv2 = std::make_unique(); auto fdv1 = std::make_unique(); auto* fdv1_ptr = fdv1.get(); - std::vector> factories; + std::vector> factories; factories.push_back(std::move(fdv2)); factories.push_back(std::move(fdv1)); SourceManager mgr(std::move(factories)); @@ -260,7 +253,7 @@ TEST(SourceManagerTest, SwitchBackToFDv2UnblocksFDv2AndBlocksFDv1) { auto fdv2 = std::make_unique(); auto* fdv2_ptr = fdv2.get(); auto fdv1 = std::make_unique(); - std::vector> factories; + std::vector> factories; factories.push_back(std::move(fdv2)); factories.push_back(std::move(fdv1)); SourceManager mgr(std::move(factories)); @@ -279,7 +272,7 @@ TEST(SourceManagerTest, SwitchBackToFDv2UnblocksFDv2AndBlocksFDv1) { TEST(SourceManagerTest, SwitchBackToFDv2UnblocksTerminallyFailedFDv2Factory) { auto fdv2 = std::make_unique(); auto* fdv2_ptr = fdv2.get(); - std::vector> factories; + std::vector> factories; factories.push_back(std::move(fdv2)); SourceManager mgr(std::move(factories)); diff --git a/libs/server-sdk/src/CMakeLists.txt b/libs/server-sdk/src/CMakeLists.txt index 6be375da6..ff2d7d75d 100644 --- a/libs/server-sdk/src/CMakeLists.txt +++ b/libs/server-sdk/src/CMakeLists.txt @@ -74,10 +74,6 @@ target_sources(${LIBNAME} data_systems/fdv2/polling_synchronizer.cpp data_systems/fdv2/streaming_synchronizer.hpp data_systems/fdv2/streaming_synchronizer.cpp - data_systems/fdv2/conditions.hpp - data_systems/fdv2/conditions.cpp - data_systems/fdv2/source_manager.hpp - data_systems/fdv2/source_manager.cpp data_systems/fdv2/fdv2_data_system.hpp data_systems/fdv2/fdv2_data_system.cpp data_systems/fdv2/fdv1_adapter_synchronizer.hpp diff --git a/libs/server-sdk/src/client_impl.cpp b/libs/server-sdk/src/client_impl.cpp index ba04dda7a..098921ad1 100644 --- a/libs/server-sdk/src/client_impl.cpp +++ b/libs/server-sdk/src/client_impl.cpp @@ -2,7 +2,6 @@ #include "all_flags_state/all_flags_state_builder.hpp" #include "data_systems/background_sync/background_sync_system.hpp" -#include "data_systems/fdv2/conditions.hpp" #include "data_systems/fdv2/fdv2_data_system.hpp" #include "data_systems/fdv2/initializer_factories.hpp" #include "data_systems/fdv2/synchronizer_factories.hpp" diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_data_system.cpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_data_system.cpp index 354fb8850..e5b3a52e0 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv2_data_system.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_data_system.cpp @@ -21,6 +21,18 @@ struct overloaded : Ts... { template overloaded(Ts...) -> overloaded; +// Reduces a source result to the signal the conditions act on. +SourceSignal ClassifyResult(data_interfaces::FDv2SourceResult const& result) { + using Result = data_interfaces::FDv2SourceResult; + if (std::get_if(&result.value)) { + return SourceSignal::kChangeSet; + } + if (std::get_if(&result.value)) { + return SourceSignal::kInterrupted; + } + return SourceSignal::kOther; +} + } // namespace FDv2DataSystem::FDv2DataSystem( @@ -28,10 +40,8 @@ FDv2DataSystem::FDv2DataSystem( initializer_factories, std::vector> synchronizer_factories, - std::unique_ptr - fallback_condition_factory, - std::unique_ptr - recovery_condition_factory, + std::unique_ptr fallback_condition_factory, + std::unique_ptr recovery_condition_factory, boost::asio::any_io_executor ioc, data_components::DataSourceStatusManager* status_manager, Logger const& logger) @@ -293,9 +303,8 @@ void FDv2DataSystem::RunSynchronizerNext() { }); } -void FDv2DataSystem::OnConditionFired( - data_interfaces::IFDv2Condition::Type type) { - using Type = data_interfaces::IFDv2Condition::Type; +void FDv2DataSystem::OnConditionFired(IFDv2Condition::Type type) { + using Type = IFDv2Condition::Type; if (type == Type::kCancelled) { return; } @@ -320,7 +329,7 @@ void FDv2DataSystem::OnConditionFired( } std::unique_ptr FDv2DataSystem::BuildActiveConditions() const { - std::vector> conditions; + std::vector> conditions; // With only one synchronizer available there's nothing to fall back to // or recover from, so leave the conditions empty. if (source_manager_.AvailableSynchronizerCount() == 1) { @@ -346,7 +355,7 @@ void FDv2DataSystem::OnSynchronizerResult( return; } if (active_conditions_) { - active_conditions_->Inform(result); + active_conditions_->Inform(ClassifyResult(result)); } } diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_data_system.hpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_data_system.hpp index 3c7147ec1..d3cc61033 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv2_data_system.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_data_system.hpp @@ -3,15 +3,14 @@ #include "../../data_components/change_notifier/change_notifier.hpp" #include "../../data_components/memory_store/memory_store.hpp" #include "../../data_components/status_notifications/data_source_status_manager.hpp" -#include "../../data_interfaces/source/ifdv2_condition.hpp" #include "../../data_interfaces/source/ifdv2_initializer_factory.hpp" #include "../../data_interfaces/source/ifdv2_synchronizer_factory.hpp" #include "../../data_interfaces/system/idata_system.hpp" -#include "conditions.hpp" -#include "source_manager.hpp" #include #include +#include +#include #include #include @@ -25,6 +24,17 @@ namespace launchdarkly::server_side::data_systems { +// The orchestration primitives the client and server SDKs share. +using internal::data_sources::Conditions; +using internal::data_sources::FallbackConditionFactory; +using internal::data_sources::IFDv2Condition; +using internal::data_sources::IFDv2ConditionFactory; +using internal::data_sources::RecoveryConditionFactory; +using internal::data_sources::SourceSignal; + +using SourceManager = internal::data_sources::SourceManager< + data_interfaces::IFDv2SynchronizerFactory>; + /** * FDv2DataSystem is the IDataSystem implementation for the FDv2 protocol. * It runs a sequence of initializers to populate an in-memory store, then @@ -163,10 +173,8 @@ class FDv2DataSystem final : public data_interfaces::IDataSystem { initializer_factories, std::vector> synchronizer_factories, - std::unique_ptr - fallback_condition_factory, - std::unique_ptr - recovery_condition_factory, + std::unique_ptr fallback_condition_factory, + std::unique_ptr recovery_condition_factory, boost::asio::any_io_executor ioc, data_components::DataSourceStatusManager* status_manager, Logger const& logger); @@ -247,7 +255,7 @@ class FDv2DataSystem final : public data_interfaces::IDataSystem { void StartSynchronizers(); void RunSynchronizerNext(); void OnSynchronizerResult(data_interfaces::FDv2SourceResult result); - void OnConditionFired(data_interfaces::IFDv2Condition::Type type); + void OnConditionFired(IFDv2Condition::Type type); // Schedules an FDv2 recovery attempt after the given TTL. Called with // mutex_ held. TTL of 0 disables the recovery and is a no-op. @@ -273,10 +281,8 @@ class FDv2DataSystem final : public data_interfaces::IDataSystem { boost::asio::any_io_executor const ioc_; std::vector> const initializer_factories_; - std::unique_ptr const - fallback_condition_factory_; - std::unique_ptr const - recovery_condition_factory_; + std::unique_ptr const fallback_condition_factory_; + std::unique_ptr const recovery_condition_factory_; // Non-owning. Lifetime guaranteed by the caller (see constructor doc). data_components::DataSourceStatusManager* const status_manager_; diff --git a/libs/server-sdk/src/data_systems/fdv2/source_manager.cpp b/libs/server-sdk/src/data_systems/fdv2/source_manager.cpp deleted file mode 100644 index 3f020e404..000000000 --- a/libs/server-sdk/src/data_systems/fdv2/source_manager.cpp +++ /dev/null @@ -1,93 +0,0 @@ -#include "source_manager.hpp" - -#include - -namespace launchdarkly::server_side::data_systems { - -using data_interfaces::IFDv2Synchronizer; -using data_interfaces::IFDv2SynchronizerFactory; - -SourceManager::SourceManager( - std::vector> factories) { - synchronizers_.reserve(factories.size()); - for (auto& factory : factories) { - bool const is_fdv1_fallback = factory->IsFDv1Fallback(); - synchronizers_.push_back(SynchronizerFactoryWithState{ - std::move(factory), - is_fdv1_fallback ? State::kBlocked : State::kAvailable, - is_fdv1_fallback}); - } -} - -std::unique_ptr SourceManager::NextSynchronizer() { - if (synchronizers_.empty()) { - current_factory_index_ = -1; - return nullptr; - } - for (std::size_t visited = 0; visited < synchronizers_.size(); ++visited) { - synchronizer_index_ = - (synchronizer_index_ + 1) % static_cast(synchronizers_.size()); - if (synchronizers_[synchronizer_index_].state == State::kAvailable) { - current_factory_index_ = synchronizer_index_; - return synchronizers_[synchronizer_index_].factory->Build(); - } - } - current_factory_index_ = -1; - return nullptr; -} - -void SourceManager::BlockCurrentSynchronizer() { - if (current_factory_index_ >= 0) { - synchronizers_[current_factory_index_].state = State::kBlocked; - } -} - -void SourceManager::ResetSourceIndex() { - synchronizer_index_ = -1; -} - -void SourceManager::SwitchToFDv1Fallback() { - for (auto& entry : synchronizers_) { - entry.state = - entry.is_fdv1_fallback ? State::kAvailable : State::kBlocked; - } - synchronizer_index_ = -1; -} - -void SourceManager::SwitchBackToFDv2() { - for (auto& entry : synchronizers_) { - entry.state = - entry.is_fdv1_fallback ? State::kBlocked : State::kAvailable; - } - synchronizer_index_ = -1; -} - -bool SourceManager::IsPrimeSynchronizer() const { - for (std::size_t i = 0; i < synchronizers_.size(); ++i) { - if (synchronizers_[i].state == State::kAvailable) { - return synchronizer_index_ == static_cast(i); - } - } - return false; -} - -std::size_t SourceManager::AvailableSynchronizerCount() const { - std::size_t count = 0; - for (auto const& s : synchronizers_) { - if (s.state == State::kAvailable) { - ++count; - } - } - return count; -} - -std::size_t SourceManager::SynchronizerCount() const { - return synchronizers_.size(); -} - -bool SourceManager::IsCurrentSynchronizerFDv1Fallback() const { - return current_factory_index_ >= 0 && - synchronizers_[current_factory_index_].is_fdv1_fallback; -} - -} // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp b/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp index 25bebefc9..7fd28185d 100644 --- a/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp +++ b/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp @@ -115,9 +115,8 @@ config::shared::built::HttpProperties MakeHttpProperties() { // requiring a real network client. class MockSseClient : public sse::Client { public: - void async_connect() override { ++connect_count_; } + void async_connect() override {} void async_shutdown(std::function completion) override { - ++shutdown_count_; if (completion) { completion(); } @@ -127,8 +126,6 @@ class MockSseClient : public sse::Client { last_restart_reason_ = reason; } - int connect_count_ = 0; - int shutdown_count_ = 0; int restart_count_ = 0; std::string last_restart_reason_; }; @@ -833,9 +830,8 @@ TEST(FDv2StreamingSynchronizerTest, DirectiveWithTtlHeaderParsesValue) { IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - "http://localhost", MakeHttpProperties(), std::nullopt, - 1s); + runner.context().get_executor(), logger, "http://localhost", + MakeHttpProperties(), std::nullopt, 1s); FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); // Server sends the directive with an explicit TTL. @@ -862,9 +858,8 @@ TEST(FDv2StreamingSynchronizerTest, DirectiveWithoutTtlHeaderUsesDefault) { IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - "http://localhost", MakeHttpProperties(), std::nullopt, - 1s); + runner.context().get_executor(), logger, "http://localhost", + MakeHttpProperties(), std::nullopt, 1s); FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); // Server sends the directive with no TTL header. diff --git a/libs/server-sent-events/src/error.cpp b/libs/server-sent-events/src/error.cpp index b4cf1c44c..9d277ad82 100644 --- a/libs/server-sent-events/src/error.cpp +++ b/libs/server-sent-events/src/error.cpp @@ -19,8 +19,11 @@ std::ostream& operator<<(std::ostream& out, NotRedirectable const&) { } std::ostream& operator<<(std::ostream& out, ReadTimeout const& err) { - out << "timed out reading response body (exceeded " << err.timeout->count() - << "ms) - will retry"; + out << "timed out reading response body"; + if (err.timeout) { + out << " (exceeded " << err.timeout->count() << "ms)"; + } + out << " - will retry"; return out; }