From 5ce3fda27f5ab1a0dc2f3068edb1603003d90bcd Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Fri, 22 May 2026 23:02:27 -0700 Subject: [PATCH 01/30] feat: orchestrator switches to FDv1 fallback on directive --- .../source/ifdv2_synchronizer_factory.hpp | 2 + .../data_systems/fdv2/fdv2_data_system.cpp | 15 +- .../src/data_systems/fdv2/source_manager.cpp | 16 ++- .../src/data_systems/fdv2/source_manager.hpp | 16 ++- .../tests/fdv2_data_system_test.cpp | 132 ++++++++++++++++++ libs/server-sdk/tests/source_manager_test.cpp | 72 +++++++++- 6 files changed, 243 insertions(+), 10 deletions(-) diff --git a/libs/server-sdk/src/data_interfaces/source/ifdv2_synchronizer_factory.hpp b/libs/server-sdk/src/data_interfaces/source/ifdv2_synchronizer_factory.hpp index fc3c6421a..ba130e768 100644 --- a/libs/server-sdk/src/data_interfaces/source/ifdv2_synchronizer_factory.hpp +++ b/libs/server-sdk/src/data_interfaces/source/ifdv2_synchronizer_factory.hpp @@ -14,6 +14,8 @@ class IFDv2SynchronizerFactory { public: virtual std::unique_ptr Build() = 0; + [[nodiscard]] virtual bool IsFDv1Fallback() const { return false; } + virtual ~IFDv2SynchronizerFactory() = default; IFDv2SynchronizerFactory(IFDv2SynchronizerFactory const&) = delete; IFDv2SynchronizerFactory(IFDv2SynchronizerFactory&&) = delete; 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 b3caa5aba..ba66bfe4f 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 @@ -185,6 +185,12 @@ void FDv2DataSystem::OnInitializerResult( if (closed_ || got_shutdown) { return; } + if (result.fdv1_fallback) { + LD_LOG(logger_, LogLevel::kInfo) + << Identity() << ": FDv1 fallback engaged"; + source_manager_.SwitchToFDv1Fallback(); + got_basis = true; + } } if (got_basis) { @@ -349,7 +355,14 @@ void FDv2DataSystem::OnSynchronizerResult( active_conditions_.reset(); return; } - if (advance) { + if (result.fdv1_fallback) { + LD_LOG(logger_, LogLevel::kInfo) + << Identity() << ": FDv1 fallback engaged"; + source_manager_.SwitchToFDv1Fallback(); + active_synchronizer_.reset(); + active_conditions_.reset(); + advance = true; + } else if (advance) { source_manager_.BlockCurrentSynchronizer(); active_synchronizer_.reset(); active_conditions_.reset(); diff --git a/libs/server-sdk/src/data_systems/fdv2/source_manager.cpp b/libs/server-sdk/src/data_systems/fdv2/source_manager.cpp index 362f13efd..4af2405a6 100644 --- a/libs/server-sdk/src/data_systems/fdv2/source_manager.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/source_manager.cpp @@ -11,9 +11,11 @@ SourceManager::SourceManager( std::vector> factories) { synchronizers_.reserve(factories.size()); for (auto& factory : factories) { - synchronizers_.push_back( - SynchronizerFactoryWithState{std::move(factory), State::kAvailable, - /*is_fdv1_fallback=*/false}); + bool const is_fdv1_fallback = factory->IsFDv1Fallback(); + synchronizers_.push_back(SynchronizerFactoryWithState{ + std::move(factory), + is_fdv1_fallback ? State::kBlocked : State::kAvailable, + is_fdv1_fallback}); } } @@ -44,6 +46,14 @@ 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; +} + bool SourceManager::IsPrimeSynchronizer() const { for (std::size_t i = 0; i < synchronizers_.size(); ++i) { if (synchronizers_[i].state == State::kAvailable) { diff --git a/libs/server-sdk/src/data_systems/fdv2/source_manager.hpp b/libs/server-sdk/src/data_systems/fdv2/source_manager.hpp index 1f310bfed..bd2347ccf 100644 --- a/libs/server-sdk/src/data_systems/fdv2/source_manager.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/source_manager.hpp @@ -23,8 +23,7 @@ namespace launchdarkly::server_side::data_systems { * by recovery, which wants to fall back to the most-preferred Available * synchronizer. * - * Each factory also carries an is_fdv1_fallback flag, currently always - * false. TODO: populate when the FDv1 fallback directive is implemented. + * Factories whose IsFDv1Fallback() returns true start in the Blocked state. * * Not thread-safe. The caller is responsible for serializing all calls. */ @@ -54,6 +53,14 @@ class SourceManager { */ void ResetSourceIndex(); + /** + * Blocks every non-FDv1 factory and unblocks the FDv1 fallback factory, + * if one was configured. Resets the iteration cursor so the next call to + * NextSynchronizer returns the FDv1 fallback. If no FDv1 fallback factory + * was configured, every factory is left blocked. + */ + void SwitchToFDv1Fallback(); + /** * Returns true if the currently tracked factory is the first Available * factory in the list. Returns false if no factory is currently tracked. @@ -73,9 +80,8 @@ class SourceManager { [[nodiscard]] std::size_t SynchronizerCount() const; /** - * Returns true if the currently tracked factory was configured as the - * FDv1 fallback synchronizer. Always false until the FDv1 fallback - * directive is implemented. + * Returns true if the currently tracked factory is the FDv1 fallback + * synchronizer. */ [[nodiscard]] bool IsCurrentSynchronizerFDv1Fallback() const; diff --git a/libs/server-sdk/tests/fdv2_data_system_test.cpp b/libs/server-sdk/tests/fdv2_data_system_test.cpp index 48d9b49d1..72ef65ff4 100644 --- a/libs/server-sdk/tests/fdv2_data_system_test.cpp +++ b/libs/server-sdk/tests/fdv2_data_system_test.cpp @@ -145,6 +145,15 @@ class OneShotSynchronizerFactory : public IFDv2SynchronizerFactory { std::unique_ptr source_; }; +class FDv1FallbackOneShotFactory : public OneShotSynchronizerFactory { + public: + explicit FDv1FallbackOneShotFactory( + std::unique_ptr source) + : OneShotSynchronizerFactory(std::move(source)) {} + + bool IsFDv1Fallback() const override { return true; } +}; + // Returns each pre-supplied source in order on successive Build() calls. // Returns nullptr once the supply is exhausted. Used in tests that exercise // wrap-around or recovery, where the same factory is built more than once. @@ -1090,6 +1099,129 @@ TEST(FDv2DataSystemTest, SingleSynchronizerHasNoFallbackArmed) { status_manager.Status().State()); } +// ============================================================================ +// FDv1 fallback directive +// ============================================================================ + +TEST(FDv2DataSystemTest, SynchronizerFdv1FlagSwitchesToFdv1Adapter) { + auto logger = MakeNullLogger(); + boost::asio::io_context ioc; + data_components::DataSourceStatusManager status_manager; + + // FDv2 synchronizer emits a ChangeSet with the directive, then closes. + auto fdv2_sync = + std::make_unique(std::vector{[]() { + FDv2SourceResult r{FDv2SourceResult::ChangeSet{ + data_model::ChangeSet{ + data_model::ChangeSetType::kNone, + {}, + data_model::Selector{}}}}; + r.fdv1_fallback = true; + return r; + }()}); + auto fdv2_factory = + std::make_unique(std::move(fdv2_sync)); + + // FDv1 adapter returns Shutdown when reached, ending orchestration. + auto fdv1_sync = + std::make_unique(std::vector{}); + auto fdv1_factory = + std::make_unique(std::move(fdv1_sync)); + auto* fdv1_factory_ptr = fdv1_factory.get(); + + std::vector> synchronizers; + synchronizers.push_back(std::move(fdv2_factory)); + synchronizers.push_back(std::move(fdv1_factory)); + + FDv2DataSystem ds({}, std::move(synchronizers), + /*fallback_condition_factory=*/nullptr, + /*recovery_condition_factory=*/nullptr, + ioc.get_executor(), &status_manager, logger); + ds.Initialize(); + ioc.run(); + + EXPECT_EQ(1, fdv1_factory_ptr->build_count_); +} + +TEST(FDv2DataSystemTest, SynchronizerFdv1FlagWithoutAdapterTransitionsOff) { + auto logger = MakeNullLogger(); + boost::asio::io_context ioc; + data_components::DataSourceStatusManager status_manager; + + auto fdv2_sync = + std::make_unique(std::vector{[]() { + FDv2SourceResult r{ + FDv2SourceResult::Interrupted{FDv2SourceResult::ErrorInfo{ + FDv2SourceResult::ErrorInfo::ErrorKind::kErrorResponse, + /*status_code=*/418, "directive", + std::chrono::system_clock::now()}}}; + r.fdv1_fallback = true; + return r; + }()}); + auto fdv2_factory = + std::make_unique(std::move(fdv2_sync)); + + std::vector> synchronizers; + synchronizers.push_back(std::move(fdv2_factory)); + + FDv2DataSystem ds({}, std::move(synchronizers), + /*fallback_condition_factory=*/nullptr, + /*recovery_condition_factory=*/nullptr, + ioc.get_executor(), &status_manager, logger); + ds.Initialize(); + ioc.run(); + + EXPECT_EQ(DataSourceStatus::DataSourceState::kOff, + status_manager.Status().State()); +} + +TEST(FDv2DataSystemTest, InitializerFdv1FlagSwitchesToFdv1Adapter) { + auto logger = MakeNullLogger(); + boost::asio::io_context ioc; + data_components::DataSourceStatusManager status_manager; + + // Initializer returns Interrupted with the directive set. + FDv2SourceResult init_result{ + FDv2SourceResult::Interrupted{FDv2SourceResult::ErrorInfo{ + FDv2SourceResult::ErrorInfo::ErrorKind::kErrorResponse, + /*status_code=*/418, "directive", + std::chrono::system_clock::now()}}}; + init_result.fdv1_fallback = true; + auto initializer = + std::make_unique(std::move(init_result)); + + std::vector> initializers; + initializers.push_back( + std::make_unique(std::move(initializer))); + + auto fdv2_sync = + std::make_unique(std::vector{}); + auto fdv2_factory = + std::make_unique(std::move(fdv2_sync)); + auto* fdv2_factory_ptr = fdv2_factory.get(); + + auto fdv1_sync = + std::make_unique(std::vector{}); + auto fdv1_factory = + std::make_unique(std::move(fdv1_sync)); + auto* fdv1_factory_ptr = fdv1_factory.get(); + + std::vector> synchronizers; + synchronizers.push_back(std::move(fdv2_factory)); + synchronizers.push_back(std::move(fdv1_factory)); + + FDv2DataSystem ds(std::move(initializers), std::move(synchronizers), + /*fallback_condition_factory=*/nullptr, + /*recovery_condition_factory=*/nullptr, + ioc.get_executor(), &status_manager, logger); + ds.Initialize(); + ioc.run(); + + // FDv2 synchronizer was skipped; FDv1 adapter was built and ran. + EXPECT_EQ(0, fdv2_factory_ptr->build_count_); + EXPECT_EQ(1, fdv1_factory_ptr->build_count_); +} + // ============================================================================ // Destruction protocol: in-flight orchestration // ============================================================================ diff --git a/libs/server-sdk/tests/source_manager_test.cpp b/libs/server-sdk/tests/source_manager_test.cpp index 244ca6400..77b207b46 100644 --- a/libs/server-sdk/tests/source_manager_test.cpp +++ b/libs/server-sdk/tests/source_manager_test.cpp @@ -43,6 +43,11 @@ class CountingFactory : public IFDv2SynchronizerFactory { int build_count = 0; }; +class FDv1FallbackFactory : public CountingFactory { + public: + bool IsFDv1Fallback() const override { return true; } +}; + } // namespace TEST(SourceManagerTest, EmptyManagerReportsZeroAvailable) { @@ -176,7 +181,7 @@ TEST(SourceManagerTest, ResetSourceIndexSkipsBlockedFirstFactory) { EXPECT_EQ(1, f1_ptr->build_count); } -TEST(SourceManagerTest, IsCurrentSynchronizerFDv1FallbackAlwaysFalse) { +TEST(SourceManagerTest, IsCurrentSynchronizerFDv1FallbackFalseForFDv2Factory) { auto f0 = std::make_unique(); std::vector> factories; factories.push_back(std::move(f0)); @@ -185,3 +190,68 @@ TEST(SourceManagerTest, IsCurrentSynchronizerFDv1FallbackAlwaysFalse) { mgr.NextSynchronizer(); EXPECT_FALSE(mgr.IsCurrentSynchronizerFDv1Fallback()); } + +TEST(SourceManagerTest, FDv1FallbackFactoryStartsBlockedAndIsSkipped) { + auto fdv2 = std::make_unique(); + auto fdv1 = std::make_unique(); + auto* fdv1_ptr = fdv1.get(); + std::vector> factories; + factories.push_back(std::move(fdv2)); + factories.push_back(std::move(fdv1)); + SourceManager mgr(std::move(factories)); + + EXPECT_EQ(1u, mgr.AvailableSynchronizerCount()); + mgr.NextSynchronizer(); + EXPECT_FALSE(mgr.IsCurrentSynchronizerFDv1Fallback()); + EXPECT_EQ(0, fdv1_ptr->build_count); +} + +TEST(SourceManagerTest, SwitchToFDv1FallbackBlocksFDv2AndUnblocksFDv1) { + auto fdv2 = std::make_unique(); + auto fdv1 = std::make_unique(); + auto* fdv1_ptr = fdv1.get(); + std::vector> factories; + factories.push_back(std::move(fdv2)); + factories.push_back(std::move(fdv1)); + SourceManager mgr(std::move(factories)); + + mgr.SwitchToFDv1Fallback(); + + EXPECT_EQ(1u, mgr.AvailableSynchronizerCount()); + auto sync = mgr.NextSynchronizer(); + ASSERT_NE(sync, nullptr); + EXPECT_EQ(1, fdv1_ptr->build_count); + EXPECT_TRUE(mgr.IsCurrentSynchronizerFDv1Fallback()); +} + +TEST(SourceManagerTest, SwitchToFDv1FallbackWithoutAdapterBlocksEverything) { + auto fdv2 = std::make_unique(); + std::vector> factories; + factories.push_back(std::move(fdv2)); + SourceManager mgr(std::move(factories)); + + mgr.SwitchToFDv1Fallback(); + + EXPECT_EQ(0u, mgr.AvailableSynchronizerCount()); + EXPECT_EQ(nullptr, mgr.NextSynchronizer()); +} + +TEST(SourceManagerTest, SwitchToFDv1FallbackUnblocksPreviouslyBlockedFDv2) { + auto fdv2 = std::make_unique(); + auto fdv1 = std::make_unique(); + auto* fdv1_ptr = fdv1.get(); + std::vector> factories; + factories.push_back(std::move(fdv2)); + factories.push_back(std::move(fdv1)); + SourceManager mgr(std::move(factories)); + + mgr.NextSynchronizer(); + mgr.BlockCurrentSynchronizer(); + mgr.SwitchToFDv1Fallback(); + + EXPECT_EQ(1u, mgr.AvailableSynchronizerCount()); + auto sync = mgr.NextSynchronizer(); + ASSERT_NE(sync, nullptr); + EXPECT_EQ(1, fdv1_ptr->build_count); + EXPECT_TRUE(mgr.IsCurrentSynchronizerFDv1Fallback()); +} From 77c972fe22a17b22c0252e619aeb2cbb7b78b84e Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Tue, 2 Jun 2026 13:29:57 -0700 Subject: [PATCH 02/30] chore: add orchestration logging for FDv2 data system --- .../data_systems/fdv2/fdv2_data_system.cpp | 68 +++++++++++-------- .../data_systems/fdv2/fdv2_data_system.hpp | 3 + 2 files changed, 44 insertions(+), 27 deletions(-) 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 ba66bfe4f..83c4b874a 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 @@ -43,6 +43,7 @@ FDv2DataSystem::FDv2DataSystem( store_(), change_notifier_(store_, store_), initialize_called_(false), + last_logged_synchronizer_interrupted_(false), closed_(false), selector_(), initializer_index_(0), @@ -121,6 +122,9 @@ void FDv2DataSystem::RunNextInitializer() { } else { auto& factory = initializer_factories_[initializer_index_++]; active_initializer_ = factory->Build(); + LD_LOG(logger_, LogLevel::kInfo) + << Identity() << ": starting initializer " + << active_initializer_->Identity(); active_initializer_->Run().Then( [this](data_interfaces::FDv2SourceResult const& result) -> std::monostate { @@ -152,6 +156,8 @@ void FDv2DataSystem::OnInitializerResult( cs.change_set.selector.value.has_value(); ApplyChangeSet(std::move(cs.change_set)); if (has_selector) { + LD_LOG(logger_, LogLevel::kInfo) + << Identity() << ": initializer succeeded"; got_basis = true; } }, @@ -211,6 +217,10 @@ void FDv2DataSystem::StartSynchronizers() { } active_synchronizer_ = source_manager_.NextSynchronizer(); if (active_synchronizer_) { + LD_LOG(logger_, LogLevel::kInfo) + << Identity() << ": starting synchronizer " + << active_synchronizer_->Identity(); + last_logged_synchronizer_interrupted_.store(false); active_conditions_ = BuildActiveConditions(); } else { exhausted = true; @@ -320,33 +330,37 @@ void FDv2DataSystem::OnSynchronizerResult( bool got_shutdown = false; bool advance = false; - std::visit(overloaded{ - [&](Result::ChangeSet& cs) { - ApplyChangeSet(std::move(cs.change_set)); - }, - [&](Result::Shutdown&) { got_shutdown = true; }, - [&](Result::Interrupted const& iv) { - LD_LOG(logger_, LogLevel::kWarn) - << Identity() << ": synchronizer interrupted: " - << iv.error.Message(); - status_manager_->SetState( - DataSourceStatus::DataSourceState::kInterrupted, - iv.error.Kind(), iv.error.Message()); - }, - [&](Result::TerminalError const& te) { - LD_LOG(logger_, LogLevel::kWarn) - << Identity() << ": synchronizer terminal error: " - << te.error.Message(); - status_manager_->SetState( - DataSourceStatus::DataSourceState::kInterrupted, - te.error.Kind(), te.error.Message()); - advance = true; - }, - [&](Result::Goodbye const&) { - // The synchronizer handles this internally. - }, - }, - result.value); + std::visit( + overloaded{ + [&](Result::ChangeSet& cs) { + last_logged_synchronizer_interrupted_.store(false); + ApplyChangeSet(std::move(cs.change_set)); + }, + [&](Result::Shutdown&) { got_shutdown = true; }, + [&](Result::Interrupted const& iv) { + if (!last_logged_synchronizer_interrupted_.exchange(true)) { + LD_LOG(logger_, LogLevel::kInfo) + << Identity() + << ": synchronizer interrupted: " << iv.error.Message(); + } + status_manager_->SetState( + DataSourceStatus::DataSourceState::kInterrupted, + iv.error.Kind(), iv.error.Message()); + }, + [&](Result::TerminalError const& te) { + LD_LOG(logger_, LogLevel::kWarn) + << Identity() + << ": synchronizer terminal error: " << te.error.Message(); + status_manager_->SetState( + DataSourceStatus::DataSourceState::kInterrupted, + te.error.Kind(), te.error.Message()); + advance = true; + }, + [&](Result::Goodbye const&) { + // The synchronizer handles this internally. + }, + }, + result.value); { std::lock_guard lock(mutex_); 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 7957de429..97e3b3c27 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 @@ -283,6 +283,9 @@ class FDv2DataSystem final : public data_interfaces::IDataSystem { // Set by Initialize() to detect repeat or concurrent calls. std::atomic_bool initialize_called_; + // Suppresses consecutive "interrupted" logs from the active synchronizer. + std::atomic_bool last_logged_synchronizer_interrupted_; + // Orchestration state, guarded by mutex_. std::mutex mutex_; bool closed_; From 5a2a9bc5a0ba94f95756f92bd79503957e50a983 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Wed, 3 Jun 2026 17:39:12 -0700 Subject: [PATCH 03/30] fix: stop emitting kOff status from FDv2DataSystem destructor --- .../data_systems/fdv2/fdv2_data_system.cpp | 1 - .../data_systems/fdv2/fdv2_data_system.hpp | 14 +++---- .../tests/fdv2_data_system_test.cpp | 39 ++++--------------- 3 files changed, 12 insertions(+), 42 deletions(-) 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 83c4b874a..695b08926 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 @@ -68,7 +68,6 @@ void FDv2DataSystem::Close() { if (active_conditions_) { active_conditions_->Close(); } - status_manager_->SetState(DataSourceStatus::DataSourceState::kOff); } std::shared_ptr FDv2DataSystem::GetFlag( 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 97e3b3c27..2eb243140 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 @@ -51,9 +51,8 @@ namespace launchdarkly::server_side::data_systems { * Destruction protocol: * * The destructor cancels in-flight orchestration (closes the active - * source, emits status kOff), but does NOT block to drain executor - * callbacks that may already be queued. Before destroying, the caller - * must ensure both of: + * source) but does NOT block to drain executor callbacks that may + * already be queued. Before destroying, the caller must ensure both of: * * 1. The executor that orchestration callbacks run on has been stopped * AND any thread running it has been joined. Otherwise a previously- @@ -120,8 +119,6 @@ namespace launchdarkly::server_side::data_systems { * v * [Done; final status preserved] * - * Calling the destructor at any time -> [Closed; status kOff]. - * * Status transitions: * * kInitializing (initial) -> kValid on first successful ChangeSet apply. @@ -130,11 +127,10 @@ namespace launchdarkly::server_side::data_systems { * the initializer phase if not yet Valid). * kOff if all initializers exhaust without data * and no synchronizers are configured. - * kValid -> kInterrupted on errors; kOff in destructor or - * when synchronizers cycle through and exhaust. + * kValid -> kInterrupted on errors; kOff when + * synchronizers cycle through and exhaust. * kInterrupted -> kValid on next successful ChangeSet apply; - * kOff in destructor or on synchronizer - * exhaustion. + * kOff on synchronizer exhaustion. * kOff -> terminal. */ class FDv2DataSystem final : public data_interfaces::IDataSystem { diff --git a/libs/server-sdk/tests/fdv2_data_system_test.cpp b/libs/server-sdk/tests/fdv2_data_system_test.cpp index 72ef65ff4..3170fcb7d 100644 --- a/libs/server-sdk/tests/fdv2_data_system_test.cpp +++ b/libs/server-sdk/tests/fdv2_data_system_test.cpp @@ -272,25 +272,6 @@ TEST(FDv2DataSystemTest, OfflineMode_NoFactories_StatusValid) { EXPECT_FALSE(ds.Initialized()); } -TEST(FDv2DataSystemTest, Destructor_TransitionsStatusToOff) { - auto logger = MakeNullLogger(); - boost::asio::io_context ioc; - data_components::DataSourceStatusManager status_manager; - - { - FDv2DataSystem ds({}, {}, /*fallback_condition_factory=*/nullptr, - /*recovery_condition_factory=*/nullptr, - ioc.get_executor(), &status_manager, logger); - ds.Initialize(); - ASSERT_EQ(status_manager.Status().State(), - DataSourceStatus::DataSourceState::kValid); - } - - // After ~FDv2DataSystem, status is Off. - EXPECT_EQ(status_manager.Status().State(), - DataSourceStatus::DataSourceState::kOff); -} - // ============================================================================ // Initializer phase // ============================================================================ @@ -1227,14 +1208,13 @@ TEST(FDv2DataSystemTest, InitializerFdv1FlagSwitchesToFdv1Adapter) { // ============================================================================ // // The destructor contract (fdv2_data_system.hpp) requires the destructor to -// cancel in-flight orchestration (close the active source, transition status -// to kOff) without firing any continuation against the destroyed object. The -// caller's responsibility is to ensure the executor is no longer running by -// the time destruction begins; the orchestrator's responsibility is to leave -// nothing dangling. These two tests pin that contract for both phases. +// cancel in-flight orchestration (close the active source) without firing +// any continuation against the destroyed object. The caller's responsibility +// is to ensure the executor is no longer running by the time destruction +// begins; the orchestrator's responsibility is to leave nothing dangling. +// These two tests pin that contract for both phases. -TEST(FDv2DataSystemTest, - Destructor_WithInFlightInitializer_ClosesSourceAndStatusOff) { +TEST(FDv2DataSystemTest, Destructor_WithInFlightInitializer_ClosesSource) { auto logger = MakeNullLogger(); boost::asio::io_context ioc; data_components::DataSourceStatusManager status_manager; @@ -1261,12 +1241,9 @@ TEST(FDv2DataSystemTest, // ~FDv2DataSystem ran with the initializer's Future still unresolved. EXPECT_TRUE(initializer_closed); - EXPECT_EQ(status_manager.Status().State(), - DataSourceStatus::DataSourceState::kOff); } -TEST(FDv2DataSystemTest, - Destructor_WithInFlightSynchronizer_ClosesSourceAndStatusOff) { +TEST(FDv2DataSystemTest, Destructor_WithInFlightSynchronizer_ClosesSource) { auto logger = MakeNullLogger(); boost::asio::io_context ioc; data_components::DataSourceStatusManager status_manager; @@ -1292,6 +1269,4 @@ TEST(FDv2DataSystemTest, } EXPECT_TRUE(synchronizer_closed); - EXPECT_EQ(status_manager.Status().State(), - DataSourceStatus::DataSourceState::kOff); } From 86b2e3d7244044142de698fc127fcac5a25aeea4 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 28 May 2026 11:04:40 -0700 Subject: [PATCH 04/30] feat: add FDv1AdapterSynchronizer wrapping IDataSynchronizer as IFDv2Synchronizer --- libs/server-sdk/src/CMakeLists.txt | 2 + .../fdv2/fdv1_adapter_synchronizer.cpp | 187 +++++++++++++++++ .../fdv2/fdv1_adapter_synchronizer.hpp | 107 ++++++++++ .../tests/fdv1_adapter_synchronizer_test.cpp | 189 ++++++++++++++++++ 4 files changed, 485 insertions(+) create mode 100644 libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.cpp create mode 100644 libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.hpp create mode 100644 libs/server-sdk/tests/fdv1_adapter_synchronizer_test.cpp diff --git a/libs/server-sdk/src/CMakeLists.txt b/libs/server-sdk/src/CMakeLists.txt index 94e12f113..176c95421 100644 --- a/libs/server-sdk/src/CMakeLists.txt +++ b/libs/server-sdk/src/CMakeLists.txt @@ -76,6 +76,8 @@ target_sources(${LIBNAME} 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 + data_systems/fdv2/fdv1_adapter_synchronizer.cpp data_systems/background_sync/sources/streaming/streaming_data_source.hpp data_systems/background_sync/sources/streaming/streaming_data_source.cpp data_systems/background_sync/sources/streaming/event_handler.hpp diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.cpp new file mode 100644 index 000000000..2dfd953cb --- /dev/null +++ b/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.cpp @@ -0,0 +1,187 @@ +#include "fdv1_adapter_synchronizer.hpp" + +#include + +namespace launchdarkly::server_side::data_systems { + +using data_interfaces::FDv2SourceResult; + +// ----- State ----- + +bool FDv1AdapterSynchronizer::State::TryStart() { + std::lock_guard lock(mutex_); + if (started_ || closed_) { + return false; + } + started_ = true; + return true; +} + +bool FDv1AdapterSynchronizer::State::MarkClosed() { + std::lock_guard lock(mutex_); + closed_ = true; + return started_; +} + +async::Future FDv1AdapterSynchronizer::State::GetNext() { + 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 FDv1AdapterSynchronizer::State::ResolvePendingAsShutdown() { + std::optional> promise; + { + std::lock_guard lock(mutex_); + if (pending_promise_) { + promise = std::move(pending_promise_); + pending_promise_.reset(); + } + } + if (promise) { + promise->Resolve(FDv2SourceResult{FDv2SourceResult::Shutdown{}}); + } +} + +void FDv1AdapterSynchronizer::State::Notify(FDv2SourceResult result) { + std::optional> promise; + { + std::lock_guard lock(mutex_); + if (closed_) { + return; + } + 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 GetNext. + promise->Resolve(std::move(result)); +} + +// ----- ConvertingDestination ----- + +FDv1AdapterSynchronizer::ConvertingDestination::ConvertingDestination( + std::weak_ptr state) + : state_(std::move(state)) {} + +void FDv1AdapterSynchronizer::ConvertingDestination::Init( + data_model::SDKDataSet data_set) { + auto state = state_.lock(); + if (!state) { + return; + } + data_interfaces::ChangeSetData changes; + changes.reserve(data_set.flags.size() + data_set.segments.size()); + for (auto& [key, flag] : data_set.flags) { + changes.push_back({key, std::move(flag)}); + } + for (auto& [key, segment] : data_set.segments) { + changes.push_back({key, std::move(segment)}); + } + state->Notify(FDv2SourceResult{FDv2SourceResult::ChangeSet{ + data_model::ChangeSet{ + data_model::ChangeSetType::kFull, std::move(changes), + data_model::Selector{}}}}); +} + +void FDv1AdapterSynchronizer::ConvertingDestination::Upsert( + std::string const& key, + data_model::FlagDescriptor flag) { + auto state = state_.lock(); + if (!state) { + return; + } + data_interfaces::ChangeSetData changes; + changes.push_back({key, std::move(flag)}); + state->Notify(FDv2SourceResult{FDv2SourceResult::ChangeSet{ + data_model::ChangeSet{ + data_model::ChangeSetType::kPartial, std::move(changes), + data_model::Selector{}}}}); +} + +void FDv1AdapterSynchronizer::ConvertingDestination::Upsert( + std::string const& key, + data_model::SegmentDescriptor segment) { + auto state = state_.lock(); + if (!state) { + return; + } + data_interfaces::ChangeSetData changes; + changes.push_back({key, std::move(segment)}); + state->Notify(FDv2SourceResult{FDv2SourceResult::ChangeSet{ + data_model::ChangeSet{ + data_model::ChangeSetType::kPartial, std::move(changes), + data_model::Selector{}}}}); +} + +std::string const& FDv1AdapterSynchronizer::ConvertingDestination::Identity() + const { + static std::string const identity = "FDv1 adapter destination"; + return identity; +} + +// ----- FDv1AdapterSynchronizer ----- + +FDv1AdapterSynchronizer::FDv1AdapterSynchronizer( + std::unique_ptr fdv1_source) + : state_(std::make_shared()), + destination_(std::make_unique(state_)), + fdv1_source_(std::move(fdv1_source)) {} + +FDv1AdapterSynchronizer::~FDv1AdapterSynchronizer() { + Close(); +} + +async::Future FDv1AdapterSynchronizer::Next( + data_model::Selector /*selector*/) { + auto closed = close_promise_.GetFuture(); + if (closed.IsFinished()) { + return async::MakeFuture( + FDv2SourceResult{FDv2SourceResult::Shutdown{}}); + } + if (state_->TryStart()) { + fdv1_source_->StartAsync(destination_.get(), + /*bootstrap_data=*/nullptr); + } + auto result_future = state_->GetNext(); + if (result_future.IsFinished()) { + return result_future; + } + return async::WhenAny(closed, result_future) + .Then( + [state = state_, result_future](std::size_t const& idx) mutable + -> async::Future { + if (idx == 0) { + state->ResolvePendingAsShutdown(); + return async::MakeFuture( + FDv2SourceResult{FDv2SourceResult::Shutdown{}}); + } + return result_future; + }, + async::kInlineExecutor); +} + +void FDv1AdapterSynchronizer::Close() { + if (!close_promise_.Resolve(std::monostate{})) { + return; + } + if (state_->MarkClosed()) { + fdv1_source_->ShutdownAsync([] {}); + } +} + +std::string const& FDv1AdapterSynchronizer::Identity() const { + static std::string const identity = "FDv1 fallback adapter"; + return identity; +} + +} // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.hpp b/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.hpp new file mode 100644 index 000000000..7d2d10a3e --- /dev/null +++ b/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.hpp @@ -0,0 +1,107 @@ +#pragma once + +#include "../../data_interfaces/destination/idestination.hpp" +#include "../../data_interfaces/source/idata_synchronizer.hpp" +#include "../../data_interfaces/source/ifdv2_synchronizer.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace launchdarkly::server_side::data_systems { + +/** + * Adapts an FDv1 IDataSynchronizer to the IFDv2Synchronizer interface. + * + * FDv1 Init/Upsert callbacks delivered through an internal IDestination are + * translated into FDv2SourceResult::ChangeSet results, with empty selectors + * and fdv1_fallback = false (the directive does not re-fire from FDv1 data). + * + * Threading: Next() and Close() may be called from any thread; only one + * Next() may be outstanding at a time. The adapter blocks in its destructor + * waiting for the FDv1 source's ShutdownAsync completion, so no callbacks + * are in flight when the wrapped source is destroyed. + */ +class FDv1AdapterSynchronizer final + : public data_interfaces::IFDv2Synchronizer { + public: + explicit FDv1AdapterSynchronizer( + std::unique_ptr fdv1_source); + + ~FDv1AdapterSynchronizer() override; + + async::Future Next( + data_model::Selector selector) override; + void Close() override; + [[nodiscard]] std::string const& Identity() const override; + + private: + /** + * Holds the lifecycle, result queue, and pending Next() promise; shared + * with the FDv1 source's IDestination via the inner ConvertingDestination. + * All methods are thread-safe. + */ + class State { + public: + // Returns true if this call transitioned Initial → Started; false if + // already started or already closed. Used to gate the one-time + // StartAsync call on the wrapped FDv1 source. + bool TryStart(); + + // Marks the state closed and returns whether the source was started + // before the transition (so the caller knows whether ShutdownAsync + // needs to be called). + bool MarkClosed(); + + async::Future GetNext(); + + // Resolves any pending Next() promise with Shutdown and clears it. + // Called on the close path so the abandoned promise doesn't leave + // potential continuations dangling. + void ResolvePendingAsShutdown(); + + void Notify(data_interfaces::FDv2SourceResult result); + + private: + // Protected by mutex_. + mutable std::mutex mutex_; + bool started_ = false; + bool closed_ = false; + std::optional> + pending_promise_; + std::deque result_queue_; + }; + + /** + * Translates FDv1 IDestination callbacks into FDv2 results queued on + * State. Thread-safe (delegates to State). + */ + class ConvertingDestination final : public data_interfaces::IDestination { + public: + explicit ConvertingDestination(std::weak_ptr state); + void Init(data_model::SDKDataSet data_set) override; + void Upsert(std::string const& key, + data_model::FlagDescriptor flag) override; + void Upsert(std::string const& key, + data_model::SegmentDescriptor segment) override; + [[nodiscard]] std::string const& Identity() const override; + + private: + std::weak_ptr state_; + }; + + // const after construction. + std::shared_ptr const state_; + std::unique_ptr const destination_; + std::unique_ptr const fdv1_source_; + + // Thread-safe primitive. + async::Promise close_promise_; +}; + +} // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/tests/fdv1_adapter_synchronizer_test.cpp b/libs/server-sdk/tests/fdv1_adapter_synchronizer_test.cpp new file mode 100644 index 000000000..77058f6bc --- /dev/null +++ b/libs/server-sdk/tests/fdv1_adapter_synchronizer_test.cpp @@ -0,0 +1,189 @@ +#include + +#include + +#include +#include +#include +#include + +using namespace launchdarkly; +using namespace launchdarkly::server_side::data_interfaces; +using namespace launchdarkly::server_side::data_systems; +using namespace std::chrono_literals; + +namespace { + +// Mock FDv1 source: records StartAsync and ShutdownAsync calls and exposes +// the IDestination it was given so the test can drive Init/Upsert. +class MockFDv1Source final : public IDataSynchronizer { + public: + void StartAsync(IDestination* destination, + data_model::SDKDataSet const* bootstrap) override { + ++start_count; + destination_ = destination; + bootstrap_was_null = (bootstrap == nullptr); + } + + void ShutdownAsync(std::function completion) override { + ++shutdown_count; + if (completion) { + completion(); + } + } + + std::string const& Identity() const override { + static std::string const id = "mock fdv1"; + return id; + } + + IDestination* destination_ = nullptr; + int start_count = 0; + int shutdown_count = 0; + bool bootstrap_was_null = false; +}; + +} // namespace + +TEST(FDv1AdapterSynchronizerTest, FirstNextStartsFDv1Source) { + auto source = std::make_unique(); + auto* source_ptr = source.get(); + FDv1AdapterSynchronizer adapter(std::move(source)); + + auto future = adapter.Next(data_model::Selector{}); + + EXPECT_EQ(1, source_ptr->start_count); + EXPECT_TRUE(source_ptr->bootstrap_was_null); + EXPECT_FALSE(future.IsFinished()); +} + +TEST(FDv1AdapterSynchronizerTest, SecondNextDoesNotRestartSource) { + auto source = std::make_unique(); + auto* source_ptr = source.get(); + FDv1AdapterSynchronizer adapter(std::move(source)); + + auto first = adapter.Next(data_model::Selector{}); + source_ptr->destination_->Init(data_model::SDKDataSet{}); + auto result = first.WaitForResult(1s); + ASSERT_TRUE(result.has_value()); + adapter.Next(data_model::Selector{}); + + EXPECT_EQ(1, source_ptr->start_count); +} + +TEST(FDv1AdapterSynchronizerTest, FDv1InitProducesFullChangeSet) { + auto source = std::make_unique(); + auto* source_ptr = source.get(); + FDv1AdapterSynchronizer adapter(std::move(source)); + + auto future = adapter.Next(data_model::Selector{}); + + data_model::SDKDataSet data_set; + data_model::Flag flag; + flag.key = "flagA"; + flag.version = 1; + data_set.flags.emplace("flagA", data_model::FlagDescriptor(flag)); + source_ptr->destination_->Init(std::move(data_set)); + + auto result = future.WaitForResult(1s); + ASSERT_TRUE(result.has_value()); + auto* change_set = std::get_if(&result->value); + ASSERT_NE(change_set, nullptr); + EXPECT_EQ(data_model::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_FALSE(change_set->change_set.selector.value.has_value()); + EXPECT_FALSE(result->fdv1_fallback); +} + +TEST(FDv1AdapterSynchronizerTest, FDv1UpsertProducesPartialChangeSet) { + auto source = std::make_unique(); + auto* source_ptr = source.get(); + FDv1AdapterSynchronizer adapter(std::move(source)); + + // Flag upsert. + auto flag_future = adapter.Next(data_model::Selector{}); + data_model::Flag flag; + flag.key = "flagA"; + flag.version = 2; + source_ptr->destination_->Upsert("flagA", data_model::FlagDescriptor(flag)); + + auto flag_result = flag_future.WaitForResult(1s); + ASSERT_TRUE(flag_result.has_value()); + auto* flag_change_set = + std::get_if(&flag_result->value); + ASSERT_NE(flag_change_set, nullptr); + EXPECT_EQ(data_model::ChangeSetType::kPartial, + flag_change_set->change_set.type); + ASSERT_EQ(1u, flag_change_set->change_set.data.size()); + EXPECT_EQ("flagA", flag_change_set->change_set.data[0].key); + + // Segment upsert. + auto seg_future = adapter.Next(data_model::Selector{}); + data_model::Segment seg; + seg.key = "segA"; + seg.version = 3; + source_ptr->destination_->Upsert("segA", + data_model::SegmentDescriptor(seg)); + + auto seg_result = seg_future.WaitForResult(1s); + ASSERT_TRUE(seg_result.has_value()); + auto* seg_change_set = + std::get_if(&seg_result->value); + ASSERT_NE(seg_change_set, nullptr); + EXPECT_EQ(data_model::ChangeSetType::kPartial, + seg_change_set->change_set.type); + ASSERT_EQ(1u, seg_change_set->change_set.data.size()); + EXPECT_EQ("segA", seg_change_set->change_set.data[0].key); +} + +TEST(FDv1AdapterSynchronizerTest, ClosePendingNextReturnsShutdown) { + auto source = std::make_unique(); + FDv1AdapterSynchronizer adapter(std::move(source)); + + auto future = adapter.Next(data_model::Selector{}); + EXPECT_FALSE(future.IsFinished()); + + adapter.Close(); + + auto result = future.WaitForResult(1s); + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE( + std::holds_alternative(result->value)); +} + +TEST(FDv1AdapterSynchronizerTest, CloseShutsDownStartedFDv1Source) { + auto source = std::make_unique(); + auto* source_ptr = source.get(); + FDv1AdapterSynchronizer adapter(std::move(source)); + + adapter.Next(data_model::Selector{}); + adapter.Close(); + + EXPECT_EQ(1, source_ptr->shutdown_count); +} + +TEST(FDv1AdapterSynchronizerTest, CloseWithoutStartDoesNotShutDownFDv1Source) { + auto source = std::make_unique(); + auto* source_ptr = source.get(); + FDv1AdapterSynchronizer adapter(std::move(source)); + + // No Next() call — FDv1 source was never started. + adapter.Close(); + + EXPECT_EQ(0, source_ptr->start_count); + EXPECT_EQ(0, source_ptr->shutdown_count); +} + +TEST(FDv1AdapterSynchronizerTest, NextAfterCloseReturnsShutdown) { + auto source = std::make_unique(); + FDv1AdapterSynchronizer adapter(std::move(source)); + + adapter.Close(); + auto future = adapter.Next(data_model::Selector{}); + + auto result = future.WaitForResult(1s); + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE( + std::holds_alternative(result->value)); +} From 8cc95b6464a5b32c6992c7a7179fcc6b29a170c8 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Tue, 2 Jun 2026 15:10:21 -0700 Subject: [PATCH 05/30] feat: add FDv2 configuration builder --- .../data_system/data_system_builder.hpp | 13 +- .../builders/data_system/fdv2_builder.hpp | 82 +++++++++ .../built/data_system/data_system_config.hpp | 3 +- .../config/built/data_system/fdv2_config.hpp | 25 +++ libs/server-sdk/src/CMakeLists.txt | 3 + libs/server-sdk/src/client_impl.cpp | 157 +++++++++++++----- .../data_system/data_system_builder.cpp | 18 +- .../config/builders/data_system/defaults.hpp | 16 ++ .../builders/data_system/fdv2_builder.cpp | 43 +++++ .../fdv2/synchronizer_factories.cpp | 72 ++++++++ .../fdv2/synchronizer_factories.hpp | 89 ++++++++++ libs/server-sdk/tests/config_builder_test.cpp | 70 ++++++++ 12 files changed, 544 insertions(+), 47 deletions(-) create mode 100644 libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp create mode 100644 libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/fdv2_config.hpp create mode 100644 libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp create mode 100644 libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp create mode 100644 libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.hpp diff --git a/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/data_system_builder.hpp b/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/data_system_builder.hpp index 795d79114..61afb9ef4 100644 --- a/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/data_system_builder.hpp +++ b/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/data_system_builder.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -13,6 +14,7 @@ class DataSystemBuilder { DataSystemBuilder(); using BackgroundSync = BackgroundSyncBuilder; using LazyLoad = LazyLoadBuilder; + using FDv2 = FDv2Builder; /** * @brief Alias for Enabled(false). @@ -46,10 +48,19 @@ class DataSystemBuilder { */ DataSystemBuilder& Method(LazyLoad lazy_load); + /** + * @brief Configures the FDv2 data system, which receives flag delivery + * updates over the new changeset-based protocol with built-in fallback + * and recovery semantics. + * @param fdv2 FDv2 configuration. + * @return Reference to this. + */ + DataSystemBuilder& Method(FDv2 fdv2); + [[nodiscard]] tl::expected Build() const; private: - std::optional> method_builder_; + std::optional> method_builder_; built::DataSystemConfig config_; }; diff --git a/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp b/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp new file mode 100644 index 000000000..eb5e35f01 --- /dev/null +++ b/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp @@ -0,0 +1,82 @@ +#pragma once + +#include +#include +#include + +#include + +namespace launchdarkly::server_side::config::builders { + +class FDv2Builder { + public: + using StreamingSource = + launchdarkly::config::shared::builders::StreamingBuilder< + launchdarkly::config::shared::ServerSDK>; + using PollingSource = + launchdarkly::config::shared::builders::PollingBuilder< + launchdarkly::config::shared::ServerSDK>; + + FDv2Builder(); + + /** + * @brief Configures the primary FDv2 streaming synchronizer. + * Defaults to the standard FDv2 streaming endpoint with no payload filter. + * @param source Streaming source configuration. + * @return Reference to this. + */ + FDv2Builder& Streaming(StreamingSource source); + + /** + * @brief Configures the secondary FDv2 polling synchronizer used as a + * fallback when streaming is unavailable. + * @param source Polling source configuration. + * @return Reference to this. + */ + FDv2Builder& Polling(PollingSource source); + + /** + * @brief Configures the FDv1 streaming source used as a last-resort + * fallback when the LaunchDarkly service signals (via the + * X-LD-FD-Fallback header) that the SDK should switch to FDv1. Enabled + * by default with standard settings. + * @param source Streaming source configuration to use for the FDv1 + * fallback connection. + * @return Reference to this. + */ + FDv2Builder& FDv1Fallback(StreamingSource source); + + /** + * @brief Disables the FDv1 streaming fallback. After this call, an + * FDv1 fallback directive from the service transitions the SDK to + * OFFLINE rather than reconnecting via FDv1. + * @return Reference to this. + */ + FDv2Builder& DisableFDv1Fallback(); + + /** + * @brief Sets how long the active synchronizer may remain interrupted + * before the orchestrator falls back to the next-preferred synchronizer. + * @param timeout Duration the synchronizer must be continuously + * interrupted for before fallback fires. + * @return Reference to this. + */ + FDv2Builder& FallbackTimeout(std::chrono::milliseconds timeout); + + /** + * @brief Sets how long a fallback synchronizer must run successfully + * before the orchestrator attempts to recover to the primary + * synchronizer. + * @param timeout Duration the fallback synchronizer must run before a + * recovery attempt is made. + * @return Reference to this. + */ + FDv2Builder& RecoveryTimeout(std::chrono::milliseconds timeout); + + [[nodiscard]] built::FDv2Config Build() const; + + private: + built::FDv2Config config_; +}; + +} // namespace launchdarkly::server_side::config::builders diff --git a/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/data_system_config.hpp b/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/data_system_config.hpp index 76dd478ed..d3697a479 100644 --- a/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/data_system_config.hpp +++ b/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/data_system_config.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -9,7 +10,7 @@ namespace launchdarkly::server_side::config::built { struct DataSystemConfig { bool disabled; - std::variant system_; + std::variant system_; }; } // namespace launchdarkly::server_side::config::built diff --git a/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/fdv2_config.hpp b/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/fdv2_config.hpp new file mode 100644 index 000000000..4e1db99a8 --- /dev/null +++ b/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/fdv2_config.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +#include +#include + +namespace launchdarkly::server_side::config::built { + +struct FDv2Config { + using StreamingConfig = + launchdarkly::config::shared::built::StreamingConfig< + launchdarkly::config::shared::ServerSDK>; + using PollingConfig = launchdarkly::config::shared::built::PollingConfig< + launchdarkly::config::shared::ServerSDK>; + + StreamingConfig streaming; + PollingConfig polling; + std::optional fdv1_fallback; + std::chrono::milliseconds fallback_timeout; + std::chrono::milliseconds recovery_timeout; +}; + +} // namespace launchdarkly::server_side::config::built diff --git a/libs/server-sdk/src/CMakeLists.txt b/libs/server-sdk/src/CMakeLists.txt index 176c95421..d1a9025d1 100644 --- a/libs/server-sdk/src/CMakeLists.txt +++ b/libs/server-sdk/src/CMakeLists.txt @@ -29,6 +29,7 @@ target_sources(${LIBNAME} config/builders/data_system/background_sync_builder.cpp config/builders/data_system/bootstrap_builder.cpp config/builders/data_system/data_system_builder.cpp + config/builders/data_system/fdv2_builder.cpp config/builders/data_system/lazy_load_builder.cpp config/builders/data_system/data_destination_builder.cpp config/builders/big_segments_builder.cpp @@ -78,6 +79,8 @@ target_sources(${LIBNAME} data_systems/fdv2/fdv2_data_system.cpp data_systems/fdv2/fdv1_adapter_synchronizer.hpp data_systems/fdv2/fdv1_adapter_synchronizer.cpp + data_systems/fdv2/synchronizer_factories.hpp + data_systems/fdv2/synchronizer_factories.cpp data_systems/background_sync/sources/streaming/streaming_data_source.hpp data_systems/background_sync/sources/streaming/streaming_data_source.cpp data_systems/background_sync/sources/streaming/event_handler.hpp diff --git a/libs/server-sdk/src/client_impl.cpp b/libs/server-sdk/src/client_impl.cpp index 9910dd23c..f4f159dd1 100644 --- a/libs/server-sdk/src/client_impl.cpp +++ b/libs/server-sdk/src/client_impl.cpp @@ -2,6 +2,9 @@ #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/synchronizer_factories.hpp" #include "data_systems/lazy_load/lazy_load_system.hpp" #include "data_systems/offline.hpp" #include "evaluation/evaluation_stack.hpp" @@ -39,16 +42,84 @@ auto const kDataSourceShutdownWait = std::chrono::milliseconds(100); // Hook method names // Method names for hooks -static const std::string kMethodBoolVariation = "BoolVariation"; -static const std::string kMethodBoolVariationDetail = "BoolVariationDetail"; -static const std::string kMethodStringVariation = "StringVariation"; -static const std::string kMethodStringVariationDetail = "StringVariationDetail"; -static const std::string kMethodDoubleVariation = "DoubleVariation"; -static const std::string kMethodDoubleVariationDetail = "DoubleVariationDetail"; -static const std::string kMethodIntVariation = "IntVariation"; -static const std::string kMethodIntVariationDetail = "IntVariationDetail"; -static const std::string kMethodJsonVariation = "JsonVariation"; -static const std::string kMethodJsonVariationDetail = "JsonVariationDetail"; +static std::string const kMethodBoolVariation = "BoolVariation"; +static std::string const kMethodBoolVariationDetail = "BoolVariationDetail"; +static std::string const kMethodStringVariation = "StringVariation"; +static std::string const kMethodStringVariationDetail = "StringVariationDetail"; +static std::string const kMethodDoubleVariation = "DoubleVariation"; +static std::string const kMethodDoubleVariationDetail = "DoubleVariationDetail"; +static std::string const kMethodIntVariation = "IntVariation"; +static std::string const kMethodIntVariationDetail = "IntVariationDetail"; +static std::string const kMethodJsonVariation = "JsonVariation"; +static std::string const kMethodJsonVariationDetail = "JsonVariationDetail"; + +namespace { + +template +struct overloaded : Ts... { + using Ts::operator()...; +}; +template +overloaded(Ts...) -> overloaded; + +} // namespace + +static std::unique_ptr MakeBackgroundSyncSystem( + config::built::ServiceEndpoints const& endpoints, + config::built::BackgroundSyncConfig const& cfg, + config::built::HttpProperties const& http_properties, + boost::asio::any_io_executor const& executor, + data_components::DataSourceStatusManager& status_manager, + Logger& logger) { + return std::make_unique( + endpoints, cfg, http_properties, executor, status_manager, logger); +} + +static std::unique_ptr MakeLazyLoadSystem( + config::built::LazyLoadConfig const& cfg, + data_components::DataSourceStatusManager& status_manager, + Logger& logger) { + return std::make_unique(logger, cfg, + status_manager); +} + +static std::unique_ptr MakeFDv2System( + config::built::ServiceEndpoints const& endpoints, + config::built::FDv2Config const& cfg, + config::built::HttpProperties const& http_properties, + boost::asio::any_io_executor const& executor, + data_components::DataSourceStatusManager& status_manager, + Logger const& logger) { + std::vector> + initializer_factories; + + std::vector> + synchronizer_factories; + synchronizer_factories.push_back( + std::make_unique( + executor, logger, endpoints, http_properties, cfg.streaming)); + synchronizer_factories.push_back( + std::make_unique( + executor, logger, endpoints, http_properties, cfg.polling)); + if (cfg.fdv1_fallback) { + synchronizer_factories.push_back( + std::make_unique( + executor, logger, &status_manager, endpoints, + *cfg.fdv1_fallback, http_properties)); + } + + auto fallback_cond_factory = + std::make_unique( + executor, cfg.fallback_timeout); + auto recovery_cond_factory = + std::make_unique( + executor, cfg.recovery_timeout); + + return std::make_unique( + std::move(initializer_factories), std::move(synchronizer_factories), + std::move(fallback_cond_factory), std::move(recovery_cond_factory), + executor, &status_manager, logger); +} static std::unique_ptr MakeDataSystem( config::built::HttpProperties const& http_properties, @@ -60,24 +131,24 @@ static std::unique_ptr MakeDataSystem( return std::make_unique(status_manager); } - auto const builder = - config::builders::HttpPropertiesBuilder(http_properties); - - auto data_source_properties = builder.Build(); + auto data_source_properties = + config::builders::HttpPropertiesBuilder(http_properties).Build(); return std::visit( - [&](auto&& arg) -> std::unique_ptr { - using T = std::decay_t; - if constexpr (std::is_same_v) { - return std::make_unique( - config.ServiceEndpoints(), arg, data_source_properties, + overloaded{ + [&](config::built::BackgroundSyncConfig const& cfg) { + return MakeBackgroundSyncSystem( + config.ServiceEndpoints(), cfg, data_source_properties, executor, status_manager, logger); - } else if constexpr (std::is_same_v< - T, config::built::LazyLoadConfig>) { - return std::make_unique(logger, arg, - status_manager); - } + }, + [&](config::built::LazyLoadConfig const& cfg) { + return MakeLazyLoadSystem(cfg, status_manager, logger); + }, + [&](config::built::FDv2Config const& cfg) { + return MakeFDv2System(config.ServiceEndpoints(), cfg, + data_source_properties, executor, + status_manager, logger); + }, }, config.DataSystemConfig().system_); } @@ -247,9 +318,9 @@ void ClientImpl::TrackInternal(Context const& ctx, std::optional data, std::optional metric_value, hooks::HookContext const& hook_context) { - if (!ctx.Valid()) { - LD_LOG(logger_, LogLevel::kWarn) << "Track method called with an invalid context"; + LD_LOG(logger_, LogLevel::kWarn) + << "Track method called with an invalid context"; return; } // Execute afterTrack hooks before moving the data @@ -259,8 +330,8 @@ void ClientImpl::TrackInternal(Context const& ctx, // In this SDK the data is type-safe, and will be enqueued, so it makes // minimal functional difference. if (!config_.Hooks().empty()) { - hooks::TrackSeriesContext series_context(ctx, event_name, metric_value, - data, hook_context, std::nullopt); + hooks::TrackSeriesContext series_context( + ctx, event_name, metric_value, data, hook_context, std::nullopt); hooks::ExecuteAfterTrack(config_.Hooks(), series_context, logger_); } @@ -367,7 +438,8 @@ EvaluationDetail ClientImpl::VariationInternal( std::optional executor; if (!config_.Hooks().empty()) { hooks::EvaluationSeriesContext series_context( - key, context, default_value, method_name, hook_context, std::nullopt); + key, context, default_value, method_name, hook_context, + std::nullopt); // Executor only created if there are hooks. executor.emplace(config_.Hooks(), logger_); executor->BeforeEvaluation(series_context); @@ -380,7 +452,8 @@ EvaluationDetail ClientImpl::VariationInternal( // Execute afterEvaluation hooks if (executor) { hooks::EvaluationSeriesContext series_context( - key, context, default_value, method_name, hook_context, std::nullopt); + key, context, default_value, method_name, hook_context, + std::nullopt); executor->AfterEvaluation(series_context, detail); } @@ -401,7 +474,8 @@ EvaluationDetail ClientImpl::VariationInternal( // Execute afterEvaluation hooks if (executor) { hooks::EvaluationSeriesContext series_context( - key, context, default_value, method_name, hook_context, std::nullopt); + key, context, default_value, method_name, hook_context, + std::nullopt); executor->AfterEvaluation(series_context, detail); } @@ -416,7 +490,8 @@ EvaluationDetail ClientImpl::VariationInternal( // Execute afterEvaluation hooks if (executor) { hooks::EvaluationSeriesContext series_context( - key, context, default_value, method_name, hook_context, std::nullopt); + key, context, default_value, method_name, hook_context, + std::nullopt); executor->AfterEvaluation(series_context, detail); } @@ -484,7 +559,8 @@ EvaluationDetail ClientImpl::BoolVariationDetail( bool default_value) { static hooks::HookContext empty_hook_context; return VariationDetail(ctx, Value::Type::kBool, key, default_value, - empty_hook_context, kMethodBoolVariationDetail); + empty_hook_context, + kMethodBoolVariationDetail); } EvaluationDetail ClientImpl::BoolVariationDetail( @@ -508,8 +584,8 @@ bool ClientImpl::BoolVariation(Context const& ctx, IClient::FlagKey const& key, bool default_value, hooks::HookContext const& hook_context) { - return Variation(ctx, Value::Type::kBool, key, default_value, - hook_context, kMethodBoolVariation); + return Variation(ctx, Value::Type::kBool, key, default_value, hook_context, + kMethodBoolVariation); } EvaluationDetail ClientImpl::StringVariationDetail( @@ -540,10 +616,11 @@ std::string ClientImpl::StringVariation(Context const& ctx, empty_hook_context, kMethodStringVariation); } -std::string ClientImpl::StringVariation(Context const& ctx, - IClient::FlagKey const& key, - std::string default_value, - hooks::HookContext const& hook_context) { +std::string ClientImpl::StringVariation( + Context const& ctx, + IClient::FlagKey const& key, + std::string default_value, + hooks::HookContext const& hook_context) { return Variation(ctx, Value::Type::kString, key, default_value, hook_context, kMethodStringVariation); } diff --git a/libs/server-sdk/src/config/builders/data_system/data_system_builder.cpp b/libs/server-sdk/src/config/builders/data_system/data_system_builder.cpp index fbf29b846..74e7d6ba5 100644 --- a/libs/server-sdk/src/config/builders/data_system/data_system_builder.cpp +++ b/libs/server-sdk/src/config/builders/data_system/data_system_builder.cpp @@ -16,6 +16,11 @@ DataSystemBuilder& DataSystemBuilder::Method(LazyLoad lazy_load) { return *this; } +DataSystemBuilder& DataSystemBuilder::Method(FDv2 fdv2) { + method_builder_ = std::move(fdv2); + return *this; +} + DataSystemBuilder& DataSystemBuilder::Enabled(bool const enabled) { config_.disabled = !enabled; return *this; @@ -27,10 +32,11 @@ DataSystemBuilder& DataSystemBuilder::Disable() { tl::expected DataSystemBuilder::Build() const { if (method_builder_) { - auto lazy_or_background_cfg = std::visit( + auto system_cfg = std::visit( [](auto&& arg) -> tl::expected, + built::BackgroundSyncConfig, + built::FDv2Config>, Error> { using T = std::decay_t; if constexpr (std::is_same_v) { @@ -39,14 +45,16 @@ tl::expected DataSystemBuilder::Build() const { return arg .Build(); // -> tl::expected + } else if constexpr (std::is_same_v) { + return arg.Build(); // -> built::FDv2Config } }, *method_builder_); - if (!lazy_or_background_cfg) { - return tl::make_unexpected(lazy_or_background_cfg.error()); + if (!system_cfg) { + return tl::make_unexpected(system_cfg.error()); } return built::DataSystemConfig{config_.disabled, - std::move(*lazy_or_background_cfg)}; + std::move(*system_cfg)}; } return config_; } diff --git a/libs/server-sdk/src/config/builders/data_system/defaults.hpp b/libs/server-sdk/src/config/builders/data_system/defaults.hpp index c8a6c5112..e5314cff0 100644 --- a/libs/server-sdk/src/config/builders/data_system/defaults.hpp +++ b/libs/server-sdk/src/config/builders/data_system/defaults.hpp @@ -2,6 +2,7 @@ #include #include #include +#include #include namespace launchdarkly::server_side::config { @@ -34,6 +35,21 @@ struct Defaults { std::chrono::minutes{5}, nullptr}; } + static auto FDv2StreamingConfig() -> built::FDv2Config::StreamingConfig { + return {std::chrono::seconds{1}, "/all"}; + } + + static auto FDv2PollingConfig() -> built::FDv2Config::PollingConfig { + return {std::chrono::seconds{30}, "/sdk/latest-all", + std::chrono::seconds{30}}; + } + + static auto FDv2Config() -> built::FDv2Config { + return {FDv2StreamingConfig(), FDv2PollingConfig(), + FDv2StreamingConfig(), std::chrono::minutes{2}, + std::chrono::minutes{5}}; + } + static auto DataSystemConfig() -> built::DataSystemConfig { return {false, BackgroundSyncConfig()}; } diff --git a/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp b/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp new file mode 100644 index 000000000..4f596e08e --- /dev/null +++ b/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp @@ -0,0 +1,43 @@ +#include + +#include "defaults.hpp" + +namespace launchdarkly::server_side::config::builders { + +FDv2Builder::FDv2Builder() : config_(Defaults::FDv2Config()) {} + +FDv2Builder& FDv2Builder::Streaming(StreamingSource source) { + config_.streaming = source.Build(); + return *this; +} + +FDv2Builder& FDv2Builder::Polling(PollingSource source) { + config_.polling = source.Build(); + return *this; +} + +FDv2Builder& FDv2Builder::FDv1Fallback(StreamingSource source) { + config_.fdv1_fallback = source.Build(); + return *this; +} + +FDv2Builder& FDv2Builder::DisableFDv1Fallback() { + config_.fdv1_fallback = std::nullopt; + return *this; +} + +FDv2Builder& FDv2Builder::FallbackTimeout(std::chrono::milliseconds timeout) { + config_.fallback_timeout = timeout; + return *this; +} + +FDv2Builder& FDv2Builder::RecoveryTimeout(std::chrono::milliseconds timeout) { + config_.recovery_timeout = timeout; + return *this; +} + +built::FDv2Config FDv2Builder::Build() const { + return config_; +} + +} // namespace launchdarkly::server_side::config::builders diff --git a/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp new file mode 100644 index 000000000..eb6253468 --- /dev/null +++ b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp @@ -0,0 +1,72 @@ +#include "synchronizer_factories.hpp" + +#include "../background_sync/sources/streaming/streaming_data_source.hpp" +#include "fdv1_adapter_synchronizer.hpp" +#include "polling_synchronizer.hpp" +#include "streaming_synchronizer.hpp" + +#include + +namespace launchdarkly::server_side::data_systems { + +FDv2StreamingSynchronizerFactory::FDv2StreamingSynchronizerFactory( + boost::asio::any_io_executor executor, + Logger logger, + config::built::ServiceEndpoints endpoints, + config::built::HttpProperties http_properties, + config::built::FDv2Config::StreamingConfig streaming) + : executor_(std::move(executor)), + logger_(std::move(logger)), + endpoints_(std::move(endpoints)), + http_properties_(std::move(http_properties)), + streaming_(std::move(streaming)) {} + +std::unique_ptr +FDv2StreamingSynchronizerFactory::Build() { + return std::make_unique( + executor_, logger_, endpoints_, http_properties_, streaming_.filter_key, + streaming_.initial_reconnect_delay); +} + +FDv2PollingSynchronizerFactory::FDv2PollingSynchronizerFactory( + boost::asio::any_io_executor executor, + Logger logger, + config::built::ServiceEndpoints endpoints, + config::built::HttpProperties http_properties, + config::built::FDv2Config::PollingConfig polling) + : executor_(std::move(executor)), + logger_(std::move(logger)), + endpoints_(std::move(endpoints)), + http_properties_(std::move(http_properties)), + polling_(std::move(polling)) {} + +std::unique_ptr +FDv2PollingSynchronizerFactory::Build() { + return std::make_unique( + executor_, logger_, endpoints_, http_properties_, polling_.filter_key, + polling_.poll_interval); +} + +FDv1StreamingAdapterFactory::FDv1StreamingAdapterFactory( + boost::asio::any_io_executor executor, + Logger logger, + data_components::DataSourceStatusManager* status_manager, + config::built::ServiceEndpoints endpoints, + config::built::FDv2Config::StreamingConfig streaming, + config::built::HttpProperties http_properties) + : executor_(std::move(executor)), + logger_(std::move(logger)), + status_manager_(status_manager), + endpoints_(std::move(endpoints)), + streaming_(std::move(streaming)), + http_properties_(std::move(http_properties)) {} + +std::unique_ptr +FDv1StreamingAdapterFactory::Build() { + auto fdv1_source = std::make_unique( + executor_, logger_, *status_manager_, endpoints_, streaming_, + http_properties_); + return std::make_unique(std::move(fdv1_source)); +} + +} // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.hpp b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.hpp new file mode 100644 index 000000000..8c14d6ba9 --- /dev/null +++ b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.hpp @@ -0,0 +1,89 @@ +#pragma once + +#include "../../data_components/status_notifications/data_source_status_manager.hpp" +#include "../../data_interfaces/source/ifdv2_synchronizer_factory.hpp" + +#include +#include +#include + +#include + +namespace launchdarkly::server_side::data_systems { + +/** + * Builds fresh FDv2StreamingSynchronizer instances on demand. + */ +class FDv2StreamingSynchronizerFactory final + : public data_interfaces::IFDv2SynchronizerFactory { + public: + FDv2StreamingSynchronizerFactory( + boost::asio::any_io_executor executor, + Logger logger, + config::built::ServiceEndpoints endpoints, + config::built::HttpProperties http_properties, + config::built::FDv2Config::StreamingConfig streaming); + + std::unique_ptr Build() override; + + private: + boost::asio::any_io_executor const executor_; + Logger const logger_; + config::built::ServiceEndpoints const endpoints_; + config::built::HttpProperties const http_properties_; + config::built::FDv2Config::StreamingConfig const streaming_; +}; + +/** + * Builds fresh FDv2PollingSynchronizer instances on demand. + */ +class FDv2PollingSynchronizerFactory final + : public data_interfaces::IFDv2SynchronizerFactory { + public: + FDv2PollingSynchronizerFactory( + boost::asio::any_io_executor executor, + Logger logger, + config::built::ServiceEndpoints endpoints, + config::built::HttpProperties http_properties, + config::built::FDv2Config::PollingConfig polling); + + std::unique_ptr Build() override; + + private: + boost::asio::any_io_executor const executor_; + Logger const logger_; + config::built::ServiceEndpoints const endpoints_; + config::built::HttpProperties const http_properties_; + config::built::FDv2Config::PollingConfig const polling_; +}; + +/** + * Builds fresh FDv1AdapterSynchronizer instances wrapping a freshly-built + * FDv1 StreamingDataSource. Reports IsFDv1Fallback() = true. + */ +class FDv1StreamingAdapterFactory final + : public data_interfaces::IFDv2SynchronizerFactory { + public: + FDv1StreamingAdapterFactory( + boost::asio::any_io_executor executor, + Logger logger, + data_components::DataSourceStatusManager* status_manager, + config::built::ServiceEndpoints endpoints, + config::built::FDv2Config::StreamingConfig streaming, + config::built::HttpProperties http_properties); + + std::unique_ptr Build() override; + + [[nodiscard]] bool IsFDv1Fallback() const override { return true; } + + private: + boost::asio::any_io_executor const executor_; + Logger const logger_; + // Non-owning. Provided by the orchestrator; must outlive this factory. + data_components::DataSourceStatusManager* const status_manager_; + config::built::ServiceEndpoints const endpoints_; + config::built::FDv2Config::StreamingConfig const streaming_; + config::built::HttpProperties const http_properties_; +}; + +} // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/tests/config_builder_test.cpp b/libs/server-sdk/tests/config_builder_test.cpp index 7e4bb0631..2f5b8ca8d 100644 --- a/libs/server-sdk/tests/config_builder_test.cpp +++ b/libs/server-sdk/tests/config_builder_test.cpp @@ -102,6 +102,76 @@ TEST_F(ConfigBuilderTest, CanSetPollingPayloadFilterKey) { EXPECT_EQ(polling_config.filter_key, "foo"); } +TEST_F(ConfigBuilderTest, FDv2_DefaultsAreUsed) { + ConfigBuilder builder("sdk-123"); + builder.DataSystem().Method(builders::DataSystemBuilder::FDv2()); + + auto cfg = builder.Build(); + + ASSERT_TRUE(std::holds_alternative( + cfg->DataSystemConfig().system_)); + auto const fdv2_config = + std::get(cfg->DataSystemConfig().system_); + + EXPECT_EQ(fdv2_config.streaming, Defaults::FDv2StreamingConfig()); + EXPECT_EQ(fdv2_config.polling.poll_interval, + Defaults::FDv2PollingConfig().poll_interval); + ASSERT_TRUE(fdv2_config.fdv1_fallback.has_value()); + EXPECT_EQ(*fdv2_config.fdv1_fallback, Defaults::FDv2StreamingConfig()); + EXPECT_EQ(fdv2_config.fallback_timeout, std::chrono::minutes{2}); + EXPECT_EQ(fdv2_config.recovery_timeout, std::chrono::minutes{5}); +} + +TEST_F(ConfigBuilderTest, FDv2_StreamingFilterFlowsThrough) { + ConfigBuilder builder("sdk-123"); + builder.DataSystem().Method(builders::DataSystemBuilder::FDv2().Streaming( + builders::FDv2Builder::StreamingSource().Filter("flag-subset"))); + + auto cfg = builder.Build(); + auto const fdv2_config = + std::get(cfg->DataSystemConfig().system_); + + EXPECT_EQ(fdv2_config.streaming.filter_key, "flag-subset"); +} + +TEST_F(ConfigBuilderTest, FDv2_PollingFilterFlowsThrough) { + ConfigBuilder builder("sdk-123"); + builder.DataSystem().Method(builders::DataSystemBuilder::FDv2().Polling( + builders::FDv2Builder::PollingSource().Filter("flag-subset"))); + + auto cfg = builder.Build(); + auto const fdv2_config = + std::get(cfg->DataSystemConfig().system_); + + EXPECT_EQ(fdv2_config.polling.filter_key, "flag-subset"); +} + +TEST_F(ConfigBuilderTest, FDv2_DisableFDv1FallbackClearsIt) { + ConfigBuilder builder("sdk-123"); + builder.DataSystem().Method( + builders::DataSystemBuilder::FDv2().DisableFDv1Fallback()); + + auto cfg = builder.Build(); + auto const fdv2_config = + std::get(cfg->DataSystemConfig().system_); + + EXPECT_FALSE(fdv2_config.fdv1_fallback.has_value()); +} + +TEST_F(ConfigBuilderTest, FDv2_FallbackAndRecoveryTimeouts) { + ConfigBuilder builder("sdk-123"); + builder.DataSystem().Method(builders::DataSystemBuilder::FDv2() + .FallbackTimeout(std::chrono::seconds{30}) + .RecoveryTimeout(std::chrono::seconds{90})); + + auto cfg = builder.Build(); + auto const fdv2_config = + std::get(cfg->DataSystemConfig().system_); + + EXPECT_EQ(fdv2_config.fallback_timeout, std::chrono::seconds{30}); + EXPECT_EQ(fdv2_config.recovery_timeout, std::chrono::seconds{90}); +} + TEST_F(ConfigBuilderTest, DefaultConstruction_HttpPropertyDefaultsAreUsed) { ConfigBuilder builder("sdk-123"); auto cfg = builder.Build(); From cdaec024c7c2e01c70291dd61d3e55c1c1973399 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Tue, 2 Jun 2026 19:43:19 -0700 Subject: [PATCH 06/30] feat: add FDv1 polling overload to FDv2Builder::FDv1Fallback --- .../builders/data_system/fdv2_builder.hpp | 18 +++++++++--- .../config/built/data_system/fdv2_config.hpp | 3 +- libs/server-sdk/src/client_impl.cpp | 23 ++++++++++++--- .../config/builders/data_system/defaults.hpp | 6 ++-- .../builders/data_system/fdv2_builder.cpp | 5 ++++ .../fdv2/synchronizer_factories.cpp | 23 +++++++++++++++ .../fdv2/synchronizer_factories.hpp | 29 +++++++++++++++++++ libs/server-sdk/tests/config_builder_test.cpp | 26 ++++++++++++++++- 8 files changed, 121 insertions(+), 12 deletions(-) diff --git a/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp b/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp index eb5e35f01..c938ddfc4 100644 --- a/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp +++ b/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp @@ -38,8 +38,8 @@ class FDv2Builder { /** * @brief Configures the FDv1 streaming source used as a last-resort * fallback when the LaunchDarkly service signals (via the - * X-LD-FD-Fallback header) that the SDK should switch to FDv1. Enabled - * by default with standard settings. + * X-LD-FD-Fallback header) that the SDK should switch to FDv1. + * Enabled by default with standard streaming settings. * @param source Streaming source configuration to use for the FDv1 * fallback connection. * @return Reference to this. @@ -47,8 +47,18 @@ class FDv2Builder { FDv2Builder& FDv1Fallback(StreamingSource source); /** - * @brief Disables the FDv1 streaming fallback. After this call, an - * FDv1 fallback directive from the service transitions the SDK to + * @brief Configures the FDv1 polling source used as a last-resort + * fallback when the LaunchDarkly service signals (via the + * X-LD-FD-Fallback header) that the SDK should switch to FDv1. + * @param source Polling source configuration to use for the FDv1 + * fallback connection. + * @return Reference to this. + */ + FDv2Builder& FDv1Fallback(PollingSource source); + + /** + * @brief Disables the FDv1 fallback. After this call, an FDv1 + * fallback directive from the service transitions the SDK to * OFFLINE rather than reconnecting via FDv1. * @return Reference to this. */ diff --git a/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/fdv2_config.hpp b/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/fdv2_config.hpp index 4e1db99a8..544bfdc44 100644 --- a/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/fdv2_config.hpp +++ b/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/fdv2_config.hpp @@ -5,6 +5,7 @@ #include #include +#include namespace launchdarkly::server_side::config::built { @@ -17,7 +18,7 @@ struct FDv2Config { StreamingConfig streaming; PollingConfig polling; - std::optional fdv1_fallback; + std::optional> fdv1_fallback; std::chrono::milliseconds fallback_timeout; std::chrono::milliseconds recovery_timeout; }; diff --git a/libs/server-sdk/src/client_impl.cpp b/libs/server-sdk/src/client_impl.cpp index f4f159dd1..4a7a83924 100644 --- a/libs/server-sdk/src/client_impl.cpp +++ b/libs/server-sdk/src/client_impl.cpp @@ -102,10 +102,25 @@ static std::unique_ptr MakeFDv2System( std::make_unique( executor, logger, endpoints, http_properties, cfg.polling)); if (cfg.fdv1_fallback) { - synchronizer_factories.push_back( - std::make_unique( - executor, logger, &status_manager, endpoints, - *cfg.fdv1_fallback, http_properties)); + std::visit( + overloaded{ + [&](config::built::FDv2Config::StreamingConfig const& + streaming) { + synchronizer_factories.push_back( + std::make_unique< + data_systems::FDv1StreamingAdapterFactory>( + executor, logger, &status_manager, endpoints, + streaming, http_properties)); + }, + [&](config::built::FDv2Config::PollingConfig const& polling) { + synchronizer_factories.push_back( + std::make_unique< + data_systems::FDv1PollingAdapterFactory>( + executor, logger, &status_manager, endpoints, + polling, http_properties)); + }, + }, + *cfg.fdv1_fallback); } auto fallback_cond_factory = diff --git a/libs/server-sdk/src/config/builders/data_system/defaults.hpp b/libs/server-sdk/src/config/builders/data_system/defaults.hpp index e5314cff0..2fcab7a89 100644 --- a/libs/server-sdk/src/config/builders/data_system/defaults.hpp +++ b/libs/server-sdk/src/config/builders/data_system/defaults.hpp @@ -46,8 +46,10 @@ struct Defaults { static auto FDv2Config() -> built::FDv2Config { return {FDv2StreamingConfig(), FDv2PollingConfig(), - FDv2StreamingConfig(), std::chrono::minutes{2}, - std::chrono::minutes{5}}; + std::variant{ + FDv2StreamingConfig()}, + std::chrono::minutes{2}, std::chrono::minutes{5}}; } static auto DataSystemConfig() -> built::DataSystemConfig { diff --git a/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp b/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp index 4f596e08e..3488bc5ad 100644 --- a/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp +++ b/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp @@ -21,6 +21,11 @@ FDv2Builder& FDv2Builder::FDv1Fallback(StreamingSource source) { return *this; } +FDv2Builder& FDv2Builder::FDv1Fallback(PollingSource source) { + config_.fdv1_fallback = source.Build(); + return *this; +} + FDv2Builder& FDv2Builder::DisableFDv1Fallback() { config_.fdv1_fallback = std::nullopt; return *this; diff --git a/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp index eb6253468..4b1798c2c 100644 --- a/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp @@ -1,5 +1,6 @@ #include "synchronizer_factories.hpp" +#include "../background_sync/sources/polling/polling_data_source.hpp" #include "../background_sync/sources/streaming/streaming_data_source.hpp" #include "fdv1_adapter_synchronizer.hpp" #include "polling_synchronizer.hpp" @@ -69,4 +70,26 @@ FDv1StreamingAdapterFactory::Build() { return std::make_unique(std::move(fdv1_source)); } +FDv1PollingAdapterFactory::FDv1PollingAdapterFactory( + boost::asio::any_io_executor executor, + Logger logger, + data_components::DataSourceStatusManager* status_manager, + config::built::ServiceEndpoints endpoints, + config::built::FDv2Config::PollingConfig polling, + config::built::HttpProperties http_properties) + : executor_(std::move(executor)), + logger_(std::move(logger)), + status_manager_(status_manager), + endpoints_(std::move(endpoints)), + polling_(std::move(polling)), + http_properties_(std::move(http_properties)) {} + +std::unique_ptr +FDv1PollingAdapterFactory::Build() { + auto fdv1_source = std::make_unique( + executor_, logger_, *status_manager_, endpoints_, polling_, + http_properties_); + return std::make_unique(std::move(fdv1_source)); +} + } // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.hpp b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.hpp index 8c14d6ba9..685a97f2d 100644 --- a/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.hpp @@ -86,4 +86,33 @@ class FDv1StreamingAdapterFactory final config::built::HttpProperties const http_properties_; }; +/** + * Builds fresh FDv1AdapterSynchronizer instances wrapping a freshly-built + * FDv1 PollingDataSource. Reports IsFDv1Fallback() = true. + */ +class FDv1PollingAdapterFactory final + : public data_interfaces::IFDv2SynchronizerFactory { + public: + FDv1PollingAdapterFactory( + boost::asio::any_io_executor executor, + Logger logger, + data_components::DataSourceStatusManager* status_manager, + config::built::ServiceEndpoints endpoints, + config::built::FDv2Config::PollingConfig polling, + config::built::HttpProperties http_properties); + + std::unique_ptr Build() override; + + [[nodiscard]] bool IsFDv1Fallback() const override { return true; } + + private: + boost::asio::any_io_executor const executor_; + Logger const logger_; + // Non-owning. Provided by the orchestrator; must outlive this factory. + data_components::DataSourceStatusManager* const status_manager_; + config::built::ServiceEndpoints const endpoints_; + config::built::FDv2Config::PollingConfig const polling_; + config::built::HttpProperties const http_properties_; +}; + } // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/tests/config_builder_test.cpp b/libs/server-sdk/tests/config_builder_test.cpp index 2f5b8ca8d..4bd1d24d1 100644 --- a/libs/server-sdk/tests/config_builder_test.cpp +++ b/libs/server-sdk/tests/config_builder_test.cpp @@ -117,11 +117,35 @@ TEST_F(ConfigBuilderTest, FDv2_DefaultsAreUsed) { EXPECT_EQ(fdv2_config.polling.poll_interval, Defaults::FDv2PollingConfig().poll_interval); ASSERT_TRUE(fdv2_config.fdv1_fallback.has_value()); - EXPECT_EQ(*fdv2_config.fdv1_fallback, Defaults::FDv2StreamingConfig()); + ASSERT_TRUE(std::holds_alternative( + *fdv2_config.fdv1_fallback)); + EXPECT_EQ(std::get( + *fdv2_config.fdv1_fallback), + Defaults::FDv2StreamingConfig()); EXPECT_EQ(fdv2_config.fallback_timeout, std::chrono::minutes{2}); EXPECT_EQ(fdv2_config.recovery_timeout, std::chrono::minutes{5}); } +TEST_F(ConfigBuilderTest, FDv2_FDv1FallbackPolling) { + ConfigBuilder builder("sdk-123"); + builder.DataSystem().Method( + builders::DataSystemBuilder::FDv2().FDv1Fallback( + builders::FDv2Builder::PollingSource().PollInterval( + std::chrono::seconds{45}))); + + auto cfg = builder.Build(); + auto const fdv2_config = + std::get(cfg->DataSystemConfig().system_); + + ASSERT_TRUE(fdv2_config.fdv1_fallback.has_value()); + ASSERT_TRUE(std::holds_alternative( + *fdv2_config.fdv1_fallback)); + EXPECT_EQ( + std::get(*fdv2_config.fdv1_fallback) + .poll_interval, + std::chrono::seconds{45}); +} + TEST_F(ConfigBuilderTest, FDv2_StreamingFilterFlowsThrough) { ConfigBuilder builder("sdk-123"); builder.DataSystem().Method(builders::DataSystemBuilder::FDv2().Streaming( From b3690cab27577368a0725daf091f150ee84e390f Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Wed, 3 Jun 2026 14:27:56 -0700 Subject: [PATCH 07/30] feat: variadic synchronizers, initializers, and per-source endpoint override in FDv2Builder --- .../shared/builders/data_source_builder.hpp | 32 ++++++ .../shared/built/data_source_config.hpp | 5 +- .../builders/data_system/fdv2_builder.hpp | 41 ++++--- .../config/built/data_system/fdv2_config.hpp | 5 +- libs/server-sdk/src/CMakeLists.txt | 2 + libs/server-sdk/src/client_impl.cpp | 33 ++++-- .../config/builders/data_system/defaults.hpp | 14 ++- .../builders/data_system/fdv2_builder.cpp | 34 ++++-- .../data_systems/fdv2/fdv2_polling_impl.cpp | 4 +- .../data_systems/fdv2/fdv2_polling_impl.hpp | 2 +- .../fdv2/initializer_factories.cpp | 31 ++++++ .../fdv2/initializer_factories.hpp | 36 +++++++ .../data_systems/fdv2/polling_initializer.cpp | 4 +- .../data_systems/fdv2/polling_initializer.hpp | 2 +- .../fdv2/polling_synchronizer.cpp | 12 +-- .../fdv2/polling_synchronizer.hpp | 6 +- .../fdv2/streaming_synchronizer.cpp | 10 +- .../fdv2/streaming_synchronizer.hpp | 6 +- .../fdv2/synchronizer_factories.cpp | 14 +-- .../fdv2/synchronizer_factories.hpp | 4 +- libs/server-sdk/tests/config_builder_test.cpp | 62 +++++++++-- .../fdv2_streaming_synchronizer_test.cpp | 102 +++++++----------- 22 files changed, 321 insertions(+), 140 deletions(-) create mode 100644 libs/server-sdk/src/data_systems/fdv2/initializer_factories.cpp create mode 100644 libs/server-sdk/src/data_systems/fdv2/initializer_factories.hpp diff --git a/libs/common/include/launchdarkly/config/shared/builders/data_source_builder.hpp b/libs/common/include/launchdarkly/config/shared/builders/data_source_builder.hpp index a69bac6e1..3755091b6 100644 --- a/libs/common/include/launchdarkly/config/shared/builders/data_source_builder.hpp +++ b/libs/common/include/launchdarkly/config/shared/builders/data_source_builder.hpp @@ -69,6 +69,22 @@ class StreamingBuilder { return *this; } + /** + * Overrides the streaming base URL for this specific source. When unset, + * the synchronizer uses the top-level ServiceEndpoints streaming URL. + * Intended for FDv2 configurations where individual synchronizers may + * target different endpoints. + * + * @param base_url The base URL to use for this source. + * @return Reference to this builder. + */ + template + std::enable_if_t::value, StreamingBuilder&> BaseUrl( + std::string base_url) { + config_.base_url_override = std::move(base_url); + return *this; + } + /** * Build the streaming config. Used internal to the SDK. * @return The built config. @@ -119,6 +135,22 @@ class PollingBuilder { return *this; } + /** + * Overrides the polling base URL for this specific source. When unset, + * the synchronizer uses the top-level ServiceEndpoints polling URL. + * Intended for FDv2 configurations where individual synchronizers may + * target different endpoints. + * + * @param base_url The base URL to use for this source. + * @return Reference to this builder. + */ + template + std::enable_if_t::value, PollingBuilder&> BaseUrl( + std::string base_url) { + config_.base_url_override = std::move(base_url); + return *this; + } + /** * Build the polling config. Used internal to the SDK. * @return The built config. diff --git a/libs/common/include/launchdarkly/config/shared/built/data_source_config.hpp b/libs/common/include/launchdarkly/config/shared/built/data_source_config.hpp index 32b9cc5b2..377f618e1 100644 --- a/libs/common/include/launchdarkly/config/shared/built/data_source_config.hpp +++ b/libs/common/include/launchdarkly/config/shared/built/data_source_config.hpp @@ -23,13 +23,15 @@ struct StreamingConfig { std::chrono::milliseconds initial_reconnect_delay; std::string streaming_path; std::optional filter_key; + std::optional base_url_override; }; inline bool operator==(StreamingConfig const& lhs, StreamingConfig const& rhs) { return lhs.initial_reconnect_delay == rhs.initial_reconnect_delay && lhs.streaming_path == rhs.streaming_path && - lhs.filter_key == rhs.filter_key; + lhs.filter_key == rhs.filter_key && + lhs.base_url_override == rhs.base_url_override; } template @@ -49,6 +51,7 @@ struct PollingConfig { std::string polling_get_path; std::chrono::seconds min_polling_interval; std::optional filter_key; + std::optional base_url_override; }; template diff --git a/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp b/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp index c938ddfc4..4527ba73e 100644 --- a/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp +++ b/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp @@ -10,30 +10,41 @@ namespace launchdarkly::server_side::config::builders { class FDv2Builder { public: - using StreamingSource = - launchdarkly::config::shared::builders::StreamingBuilder< - launchdarkly::config::shared::ServerSDK>; - using PollingSource = - launchdarkly::config::shared::builders::PollingBuilder< - launchdarkly::config::shared::ServerSDK>; + using Streaming = launchdarkly::config::shared::builders::StreamingBuilder< + launchdarkly::config::shared::ServerSDK>; + using Polling = launchdarkly::config::shared::builders::PollingBuilder< + launchdarkly::config::shared::ServerSDK>; FDv2Builder(); /** - * @brief Configures the primary FDv2 streaming synchronizer. - * Defaults to the standard FDv2 streaming endpoint with no payload filter. + * @brief Appends a polling initializer to the initializers list. The + * first call to this method on a default-constructed builder replaces + * the spec-default initializer list; subsequent calls append. + * @param source Polling source configuration for the initializer. + * @return Reference to this. + */ + FDv2Builder& Initializer(Polling source); + + /** + * @brief Appends a streaming synchronizer to the synchronizers list. + * Order in the list determines preference: the first entry is the + * primary synchronizer, subsequent entries are fallbacks. The first + * call to a Synchronizer overload on a default-constructed builder + * replaces the spec-default synchronizer list; subsequent calls append. * @param source Streaming source configuration. * @return Reference to this. */ - FDv2Builder& Streaming(StreamingSource source); + FDv2Builder& Synchronizer(Streaming source); /** - * @brief Configures the secondary FDv2 polling synchronizer used as a - * fallback when streaming is unavailable. + * @brief Appends a polling synchronizer to the synchronizers list. See + * Synchronizer(Streaming) for ordering and default-replacement + * semantics. * @param source Polling source configuration. * @return Reference to this. */ - FDv2Builder& Polling(PollingSource source); + FDv2Builder& Synchronizer(Polling source); /** * @brief Configures the FDv1 streaming source used as a last-resort @@ -44,7 +55,7 @@ class FDv2Builder { * fallback connection. * @return Reference to this. */ - FDv2Builder& FDv1Fallback(StreamingSource source); + FDv2Builder& FDv1Fallback(Streaming source); /** * @brief Configures the FDv1 polling source used as a last-resort @@ -54,7 +65,7 @@ class FDv2Builder { * fallback connection. * @return Reference to this. */ - FDv2Builder& FDv1Fallback(PollingSource source); + FDv2Builder& FDv1Fallback(Polling source); /** * @brief Disables the FDv1 fallback. After this call, an FDv1 @@ -87,6 +98,8 @@ class FDv2Builder { private: built::FDv2Config config_; + bool initializers_explicit_; + bool synchronizers_explicit_; }; } // namespace launchdarkly::server_side::config::builders diff --git a/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/fdv2_config.hpp b/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/fdv2_config.hpp index 544bfdc44..4edf7e45f 100644 --- a/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/fdv2_config.hpp +++ b/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/fdv2_config.hpp @@ -6,6 +6,7 @@ #include #include #include +#include namespace launchdarkly::server_side::config::built { @@ -16,8 +17,8 @@ struct FDv2Config { using PollingConfig = launchdarkly::config::shared::built::PollingConfig< launchdarkly::config::shared::ServerSDK>; - StreamingConfig streaming; - PollingConfig polling; + std::vector initializers; + std::vector> synchronizers; std::optional> fdv1_fallback; std::chrono::milliseconds fallback_timeout; std::chrono::milliseconds recovery_timeout; diff --git a/libs/server-sdk/src/CMakeLists.txt b/libs/server-sdk/src/CMakeLists.txt index d1a9025d1..d60f63eac 100644 --- a/libs/server-sdk/src/CMakeLists.txt +++ b/libs/server-sdk/src/CMakeLists.txt @@ -81,6 +81,8 @@ target_sources(${LIBNAME} data_systems/fdv2/fdv1_adapter_synchronizer.cpp data_systems/fdv2/synchronizer_factories.hpp data_systems/fdv2/synchronizer_factories.cpp + data_systems/fdv2/initializer_factories.hpp + data_systems/fdv2/initializer_factories.cpp data_systems/background_sync/sources/streaming/streaming_data_source.hpp data_systems/background_sync/sources/streaming/streaming_data_source.cpp data_systems/background_sync/sources/streaming/event_handler.hpp diff --git a/libs/server-sdk/src/client_impl.cpp b/libs/server-sdk/src/client_impl.cpp index 4a7a83924..048f062d6 100644 --- a/libs/server-sdk/src/client_impl.cpp +++ b/libs/server-sdk/src/client_impl.cpp @@ -4,6 +4,7 @@ #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" #include "data_systems/lazy_load/lazy_load_system.hpp" #include "data_systems/offline.hpp" @@ -92,15 +93,35 @@ static std::unique_ptr MakeFDv2System( Logger const& logger) { std::vector> initializer_factories; + for (auto const& initializer : cfg.initializers) { + initializer_factories.push_back( + std::make_unique( + executor, logger, endpoints, http_properties, initializer)); + } std::vector> synchronizer_factories; - synchronizer_factories.push_back( - std::make_unique( - executor, logger, endpoints, http_properties, cfg.streaming)); - synchronizer_factories.push_back( - std::make_unique( - executor, logger, endpoints, http_properties, cfg.polling)); + for (auto const& sync : cfg.synchronizers) { + std::visit( + overloaded{ + [&](config::built::FDv2Config::StreamingConfig const& + streaming) { + synchronizer_factories.push_back( + std::make_unique< + data_systems::FDv2StreamingSynchronizerFactory>( + executor, logger, endpoints, http_properties, + streaming)); + }, + [&](config::built::FDv2Config::PollingConfig const& polling) { + synchronizer_factories.push_back( + std::make_unique< + data_systems::FDv2PollingSynchronizerFactory>( + executor, logger, endpoints, http_properties, + polling)); + }, + }, + sync); + } if (cfg.fdv1_fallback) { std::visit( overloaded{ diff --git a/libs/server-sdk/src/config/builders/data_system/defaults.hpp b/libs/server-sdk/src/config/builders/data_system/defaults.hpp index 2fcab7a89..b97383d0c 100644 --- a/libs/server-sdk/src/config/builders/data_system/defaults.hpp +++ b/libs/server-sdk/src/config/builders/data_system/defaults.hpp @@ -45,11 +45,15 @@ struct Defaults { } static auto FDv2Config() -> built::FDv2Config { - return {FDv2StreamingConfig(), FDv2PollingConfig(), - std::variant{ - FDv2StreamingConfig()}, - std::chrono::minutes{2}, std::chrono::minutes{5}}; + using StreamingConfig = built::FDv2Config::StreamingConfig; + using PollingConfig = built::FDv2Config::PollingConfig; + using SyncVariant = std::variant; + return {{FDv2PollingConfig()}, + std::vector{FDv2StreamingConfig(), + FDv2PollingConfig()}, + SyncVariant{FDv2StreamingConfig()}, + std::chrono::minutes{2}, + std::chrono::minutes{5}}; } static auto DataSystemConfig() -> built::DataSystemConfig { diff --git a/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp b/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp index 3488bc5ad..e54573d5a 100644 --- a/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp +++ b/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp @@ -4,24 +4,44 @@ namespace launchdarkly::server_side::config::builders { -FDv2Builder::FDv2Builder() : config_(Defaults::FDv2Config()) {} +FDv2Builder::FDv2Builder() + : config_(Defaults::FDv2Config()), + initializers_explicit_(false), + synchronizers_explicit_(false) {} -FDv2Builder& FDv2Builder::Streaming(StreamingSource source) { - config_.streaming = source.Build(); +FDv2Builder& FDv2Builder::Initializer(Polling source) { + if (!initializers_explicit_) { + config_.initializers.clear(); + initializers_explicit_ = true; + } + config_.initializers.push_back(source.Build()); return *this; } -FDv2Builder& FDv2Builder::Polling(PollingSource source) { - config_.polling = source.Build(); +FDv2Builder& FDv2Builder::Synchronizer(Streaming source) { + if (!synchronizers_explicit_) { + config_.synchronizers.clear(); + synchronizers_explicit_ = true; + } + config_.synchronizers.push_back(source.Build()); return *this; } -FDv2Builder& FDv2Builder::FDv1Fallback(StreamingSource source) { +FDv2Builder& FDv2Builder::Synchronizer(Polling source) { + if (!synchronizers_explicit_) { + config_.synchronizers.clear(); + synchronizers_explicit_ = true; + } + config_.synchronizers.push_back(source.Build()); + return *this; +} + +FDv2Builder& FDv2Builder::FDv1Fallback(Streaming source) { config_.fdv1_fallback = source.Build(); return *this; } -FDv2Builder& FDv2Builder::FDv1Fallback(PollingSource source) { +FDv2Builder& FDv2Builder::FDv1Fallback(Polling source) { config_.fdv1_fallback = source.Build(); return *this; } diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp index 1178aff46..fb229ed58 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp @@ -41,14 +41,14 @@ static bool ReadFDv1FallbackDirective( } network::HttpRequest MakeFDv2PollRequest( - config::built::ServiceEndpoints const& endpoints, + std::string const& polling_base_url, config::built::HttpProperties const& http_properties, data_model::Selector const& selector, std::optional const& filter_key, Logger const& logger) { config::builders::HttpPropertiesBuilder const builder(http_properties); - auto parsed = boost::urls::parse_uri(endpoints.PollingBaseUrl()); + auto parsed = boost::urls::parse_uri(polling_base_url); if (!parsed) { return {"", network::HttpMethod::kGet, builder.Build(), network::HttpRequest::BodyType{}}; diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp index 047a4788d..94f90f1dc 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp @@ -16,7 +16,7 @@ namespace launchdarkly::server_side::data_systems { // Build a polling HTTP GET request for the FDv2 endpoint. network::HttpRequest MakeFDv2PollRequest( - config::built::ServiceEndpoints const& endpoints, + std::string const& polling_base_url, config::built::HttpProperties const& http_properties, data_model::Selector const& selector, std::optional const& filter_key, diff --git a/libs/server-sdk/src/data_systems/fdv2/initializer_factories.cpp b/libs/server-sdk/src/data_systems/fdv2/initializer_factories.cpp new file mode 100644 index 000000000..316f4c615 --- /dev/null +++ b/libs/server-sdk/src/data_systems/fdv2/initializer_factories.cpp @@ -0,0 +1,31 @@ +#include "initializer_factories.hpp" + +#include "polling_initializer.hpp" + +#include + +#include + +namespace launchdarkly::server_side::data_systems { + +FDv2PollingInitializerFactory::FDv2PollingInitializerFactory( + boost::asio::any_io_executor executor, + Logger logger, + config::built::ServiceEndpoints endpoints, + config::built::HttpProperties http_properties, + config::built::FDv2Config::PollingConfig polling) + : executor_(std::move(executor)), + logger_(std::move(logger)), + polling_base_url_( + polling.base_url_override.value_or(endpoints.PollingBaseUrl())), + http_properties_(std::move(http_properties)), + polling_(std::move(polling)) {} + +std::unique_ptr +FDv2PollingInitializerFactory::Build() { + return std::make_unique( + executor_, logger_, polling_base_url_, http_properties_, + data_model::Selector{}, polling_.filter_key); +} + +} // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/fdv2/initializer_factories.hpp b/libs/server-sdk/src/data_systems/fdv2/initializer_factories.hpp new file mode 100644 index 000000000..ea97dc374 --- /dev/null +++ b/libs/server-sdk/src/data_systems/fdv2/initializer_factories.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "../../data_interfaces/source/ifdv2_initializer_factory.hpp" + +#include +#include +#include + +#include + +namespace launchdarkly::server_side::data_systems { + +/** + * Builds fresh FDv2PollingInitializer instances on demand. + */ +class FDv2PollingInitializerFactory final + : public data_interfaces::IFDv2InitializerFactory { + public: + FDv2PollingInitializerFactory( + boost::asio::any_io_executor executor, + Logger logger, + config::built::ServiceEndpoints endpoints, + config::built::HttpProperties http_properties, + config::built::FDv2Config::PollingConfig polling); + + std::unique_ptr Build() override; + + private: + boost::asio::any_io_executor const executor_; + Logger const logger_; + std::string const polling_base_url_; + config::built::HttpProperties const http_properties_; + config::built::FDv2Config::PollingConfig const polling_; +}; + +} // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp index 2f07d95b2..b8b47f7b5 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp @@ -13,11 +13,11 @@ using data_interfaces::FDv2SourceResult; FDv2PollingInitializer::FDv2PollingInitializer( boost::asio::any_io_executor const& executor, Logger const& logger, - config::built::ServiceEndpoints const& endpoints, + std::string const& polling_base_url, config::built::HttpProperties const& http_properties, data_model::Selector selector, std::optional filter_key) - : request_(MakeFDv2PollRequest(endpoints, + : request_(MakeFDv2PollRequest(polling_base_url, http_properties, std::move(selector), std::move(filter_key), diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp index 0fb87c4d0..8d27a549f 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp @@ -35,7 +35,7 @@ class FDv2PollingInitializer final : public data_interfaces::IFDv2Initializer { */ FDv2PollingInitializer(boost::asio::any_io_executor const& executor, Logger const& logger, - config::built::ServiceEndpoints const& endpoints, + std::string const& polling_base_url, config::built::HttpProperties const& http_properties, data_model::Selector selector, std::optional filter_key); diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp index 5fce4c07a..33ea500b8 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp @@ -20,12 +20,12 @@ FDv2PollingSynchronizer::State::State( Logger logger, boost::asio::any_io_executor const& executor, std::chrono::seconds poll_interval, - config::built::ServiceEndpoints const& endpoints, + std::string polling_base_url, config::built::HttpProperties const& http_properties, std::optional filter_key) : logger_(std::move(logger)), poll_interval_(std::max(poll_interval, kMinPollInterval)), - endpoints_(endpoints), + polling_base_url_(std::move(polling_base_url)), http_properties_(http_properties), filter_key_(std::move(filter_key)), requester_(executor, http_properties.Tls()), @@ -33,8 +33,8 @@ FDv2PollingSynchronizer::State::State( async::Future FDv2PollingSynchronizer::State::Request( data_model::Selector const& selector) const { - auto request = MakeFDv2PollRequest(endpoints_, http_properties_, selector, - filter_key_, logger_); + auto request = MakeFDv2PollRequest(polling_base_url_, http_properties_, + selector, filter_key_, logger_); // Promise must be in a shared_ptr because Requester requires callbacks // to be copy-constructible (stored in std::function). @@ -81,14 +81,14 @@ void FDv2PollingSynchronizer::State::RecordPollStarted() { FDv2PollingSynchronizer::FDv2PollingSynchronizer( boost::asio::any_io_executor const& executor, Logger const& logger, - config::built::ServiceEndpoints const& endpoints, + std::string polling_base_url, config::built::HttpProperties const& http_properties, std::optional filter_key, std::chrono::seconds poll_interval) : state_(std::make_shared(logger, executor, poll_interval, - endpoints, + std::move(polling_base_url), http_properties, std::move(filter_key))) { if (poll_interval < kMinPollInterval) { diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp index 954acbc9d..3b03511ce 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp @@ -39,7 +39,7 @@ class FDv2PollingSynchronizer final FDv2PollingSynchronizer( boost::asio::any_io_executor const& executor, Logger const& logger, - config::built::ServiceEndpoints const& endpoints, + std::string polling_base_url, config::built::HttpProperties const& http_properties, std::optional filter_key, std::chrono::seconds poll_interval); @@ -62,7 +62,7 @@ class FDv2PollingSynchronizer final State(Logger logger, boost::asio::any_io_executor const& executor, std::chrono::seconds poll_interval, - config::built::ServiceEndpoints const& endpoints, + std::string polling_base_url, config::built::HttpProperties const& http_properties, std::optional filter_key); @@ -96,7 +96,7 @@ class FDv2PollingSynchronizer final // Immutable state std::chrono::seconds const poll_interval_; - config::built::ServiceEndpoints const endpoints_; + std::string const polling_base_url_; config::built::HttpProperties const http_properties_; std::optional const filter_key_; network::Requester const requester_; diff --git a/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp index c43483fc3..e53ae3af2 100644 --- a/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp @@ -37,12 +37,12 @@ inline constexpr bool always_false_v = false; FDv2StreamingSynchronizer::State::State( Logger logger, boost::asio::any_io_executor const& executor, - config::built::ServiceEndpoints const& endpoints, + std::string streaming_base_url, config::built::HttpProperties const& http_properties, std::optional filter_key, std::chrono::milliseconds initial_reconnect_delay) : logger_(std::move(logger)), - endpoints_(endpoints), + streaming_base_url_(std::move(streaming_base_url)), http_properties_(http_properties), filter_key_(std::move(filter_key)), initial_reconnect_delay_(initial_reconnect_delay), @@ -60,7 +60,7 @@ void FDv2StreamingSynchronizer::State::EnsureStarted( started_ = true; } - auto parsed = boost::urls::parse_uri(endpoints_.StreamingBaseUrl()); + auto parsed = boost::urls::parse_uri(streaming_base_url_); if (!parsed) { // started_ intentionally left true: a bad endpoint URL is a // configuration error that won't fix itself. The TerminalError @@ -359,13 +359,13 @@ void FDv2StreamingSynchronizer::State::Shutdown() { FDv2StreamingSynchronizer::FDv2StreamingSynchronizer( boost::asio::any_io_executor const& executor, Logger const& logger, - config::built::ServiceEndpoints const& endpoints, + std::string streaming_base_url, config::built::HttpProperties const& http_properties, std::optional filter_key, std::chrono::milliseconds initial_reconnect_delay) : state_(std::make_shared(logger, executor, - endpoints, + std::move(streaming_base_url), http_properties, std::move(filter_key), initial_reconnect_delay)) {} diff --git a/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.hpp b/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.hpp index 1abed1f64..7a0f12418 100644 --- a/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.hpp @@ -46,7 +46,7 @@ class FDv2StreamingSynchronizer final FDv2StreamingSynchronizer( boost::asio::any_io_executor const& executor, Logger const& logger, - config::built::ServiceEndpoints const& endpoints, + std::string streaming_base_url, config::built::HttpProperties const& http_properties, std::optional filter_key, std::chrono::milliseconds initial_reconnect_delay); @@ -70,7 +70,7 @@ class FDv2StreamingSynchronizer final public: State(Logger logger, boost::asio::any_io_executor const& executor, - config::built::ServiceEndpoints const& endpoints, + std::string streaming_base_url, config::built::HttpProperties const& http_properties, std::optional filter_key, std::chrono::milliseconds initial_reconnect_delay); @@ -132,7 +132,7 @@ class FDv2StreamingSynchronizer final Logger logger_; // Immutable state - config::built::ServiceEndpoints const endpoints_; + std::string const streaming_base_url_; config::built::HttpProperties const http_properties_; std::optional const filter_key_; std::chrono::milliseconds const initial_reconnect_delay_; diff --git a/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp index 4b1798c2c..9a09243c4 100644 --- a/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp @@ -18,15 +18,16 @@ FDv2StreamingSynchronizerFactory::FDv2StreamingSynchronizerFactory( config::built::FDv2Config::StreamingConfig streaming) : executor_(std::move(executor)), logger_(std::move(logger)), - endpoints_(std::move(endpoints)), + streaming_base_url_( + streaming.base_url_override.value_or(endpoints.StreamingBaseUrl())), http_properties_(std::move(http_properties)), streaming_(std::move(streaming)) {} std::unique_ptr FDv2StreamingSynchronizerFactory::Build() { return std::make_unique( - executor_, logger_, endpoints_, http_properties_, streaming_.filter_key, - streaming_.initial_reconnect_delay); + executor_, logger_, streaming_base_url_, http_properties_, + streaming_.filter_key, streaming_.initial_reconnect_delay); } FDv2PollingSynchronizerFactory::FDv2PollingSynchronizerFactory( @@ -37,15 +38,16 @@ FDv2PollingSynchronizerFactory::FDv2PollingSynchronizerFactory( config::built::FDv2Config::PollingConfig polling) : executor_(std::move(executor)), logger_(std::move(logger)), - endpoints_(std::move(endpoints)), + polling_base_url_( + polling.base_url_override.value_or(endpoints.PollingBaseUrl())), http_properties_(std::move(http_properties)), polling_(std::move(polling)) {} std::unique_ptr FDv2PollingSynchronizerFactory::Build() { return std::make_unique( - executor_, logger_, endpoints_, http_properties_, polling_.filter_key, - polling_.poll_interval); + executor_, logger_, polling_base_url_, http_properties_, + polling_.filter_key, polling_.poll_interval); } FDv1StreamingAdapterFactory::FDv1StreamingAdapterFactory( diff --git a/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.hpp b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.hpp index 685a97f2d..f15c35fc8 100644 --- a/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.hpp @@ -29,7 +29,7 @@ class FDv2StreamingSynchronizerFactory final private: boost::asio::any_io_executor const executor_; Logger const logger_; - config::built::ServiceEndpoints const endpoints_; + std::string const streaming_base_url_; config::built::HttpProperties const http_properties_; config::built::FDv2Config::StreamingConfig const streaming_; }; @@ -52,7 +52,7 @@ class FDv2PollingSynchronizerFactory final private: boost::asio::any_io_executor const executor_; Logger const logger_; - config::built::ServiceEndpoints const endpoints_; + std::string const polling_base_url_; config::built::HttpProperties const http_properties_; config::built::FDv2Config::PollingConfig const polling_; }; diff --git a/libs/server-sdk/tests/config_builder_test.cpp b/libs/server-sdk/tests/config_builder_test.cpp index 4bd1d24d1..52ac9fdd8 100644 --- a/libs/server-sdk/tests/config_builder_test.cpp +++ b/libs/server-sdk/tests/config_builder_test.cpp @@ -113,9 +113,19 @@ TEST_F(ConfigBuilderTest, FDv2_DefaultsAreUsed) { auto const fdv2_config = std::get(cfg->DataSystemConfig().system_); - EXPECT_EQ(fdv2_config.streaming, Defaults::FDv2StreamingConfig()); - EXPECT_EQ(fdv2_config.polling.poll_interval, + ASSERT_EQ(fdv2_config.initializers.size(), 1u); + EXPECT_EQ(fdv2_config.initializers[0].poll_interval, Defaults::FDv2PollingConfig().poll_interval); + + ASSERT_EQ(fdv2_config.synchronizers.size(), 2u); + ASSERT_TRUE(std::holds_alternative( + fdv2_config.synchronizers[0])); + EXPECT_EQ(std::get( + fdv2_config.synchronizers[0]), + Defaults::FDv2StreamingConfig()); + ASSERT_TRUE(std::holds_alternative( + fdv2_config.synchronizers[1])); + ASSERT_TRUE(fdv2_config.fdv1_fallback.has_value()); ASSERT_TRUE(std::holds_alternative( *fdv2_config.fdv1_fallback)); @@ -130,7 +140,7 @@ TEST_F(ConfigBuilderTest, FDv2_FDv1FallbackPolling) { ConfigBuilder builder("sdk-123"); builder.DataSystem().Method( builders::DataSystemBuilder::FDv2().FDv1Fallback( - builders::FDv2Builder::PollingSource().PollInterval( + builders::FDv2Builder::Polling().PollInterval( std::chrono::seconds{45}))); auto cfg = builder.Build(); @@ -146,28 +156,58 @@ TEST_F(ConfigBuilderTest, FDv2_FDv1FallbackPolling) { std::chrono::seconds{45}); } -TEST_F(ConfigBuilderTest, FDv2_StreamingFilterFlowsThrough) { +TEST_F(ConfigBuilderTest, FDv2_MultipleSynchronizers) { ConfigBuilder builder("sdk-123"); - builder.DataSystem().Method(builders::DataSystemBuilder::FDv2().Streaming( - builders::FDv2Builder::StreamingSource().Filter("flag-subset"))); + builder.DataSystem().Method( + builders::DataSystemBuilder::FDv2() + .Synchronizer(builders::FDv2Builder::Polling().PollInterval( + std::chrono::seconds{45})) + .Synchronizer(builders::FDv2Builder::Streaming().Filter("filt"))); auto cfg = builder.Build(); auto const fdv2_config = std::get(cfg->DataSystemConfig().system_); - EXPECT_EQ(fdv2_config.streaming.filter_key, "flag-subset"); + ASSERT_EQ(fdv2_config.synchronizers.size(), 2u); + ASSERT_TRUE(std::holds_alternative( + fdv2_config.synchronizers[0])); + ASSERT_TRUE(std::holds_alternative( + fdv2_config.synchronizers[1])); + EXPECT_EQ(std::get( + fdv2_config.synchronizers[1]) + .filter_key, + "filt"); } -TEST_F(ConfigBuilderTest, FDv2_PollingFilterFlowsThrough) { +TEST_F(ConfigBuilderTest, FDv2_AddingInitializerClearsDefaults) { ConfigBuilder builder("sdk-123"); - builder.DataSystem().Method(builders::DataSystemBuilder::FDv2().Polling( - builders::FDv2Builder::PollingSource().Filter("flag-subset"))); + builder.DataSystem().Method(builders::DataSystemBuilder::FDv2().Initializer( + builders::FDv2Builder::Polling().Filter("flag-subset"))); + + auto cfg = builder.Build(); + auto const fdv2_config = + std::get(cfg->DataSystemConfig().system_); + + ASSERT_EQ(fdv2_config.initializers.size(), 1u); + EXPECT_EQ(fdv2_config.initializers[0].filter_key, "flag-subset"); +} + +TEST_F(ConfigBuilderTest, FDv2_PerSourceBaseUrlOverride) { + ConfigBuilder builder("sdk-123"); + builder.DataSystem().Method( + builders::DataSystemBuilder::FDv2().Synchronizer( + builders::FDv2Builder::Streaming().BaseUrl( + "https://example.test"))); auto cfg = builder.Build(); auto const fdv2_config = std::get(cfg->DataSystemConfig().system_); - EXPECT_EQ(fdv2_config.polling.filter_key, "flag-subset"); + ASSERT_EQ(fdv2_config.synchronizers.size(), 1u); + auto const& sync = std::get( + fdv2_config.synchronizers[0]); + ASSERT_TRUE(sync.base_url_override.has_value()); + EXPECT_EQ(*sync.base_url_override, "https://example.test"); } TEST_F(ConfigBuilderTest, FDv2_DisableFDv1FallbackClearsIt) { diff --git a/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp b/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp index 68687adb5..ed748bf31 100644 --- a/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp +++ b/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp @@ -105,11 +105,6 @@ class IoContextRunner { std::thread thread_; }; -config::shared::built::ServiceEndpoints MakeEndpoints(std::string streaming) { - return config::shared::built::ServiceEndpoints( - "polling", std::move(streaming), "events"); -} - config::shared::built::HttpProperties MakeHttpProperties() { return launchdarkly::server_side::config::builders::HttpPropertiesBuilder() .Build(); @@ -149,7 +144,7 @@ TEST(FDv2StreamingSynchronizerTest, NextBadEndpointUrlReturnsTerminalError) { IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, MakeEndpoints("not a url"), + runner.context().get_executor(), logger, "not a url", MakeHttpProperties(), std::nullopt, 1s); // Act: trigger setup with a malformed streaming URL. URL parsing happens @@ -172,9 +167,8 @@ TEST(FDv2StreamingSynchronizerTest, CloseBeforeNextReturnsShutdown) { IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - MakeEndpoints("http://localhost"), MakeHttpProperties(), std::nullopt, - 1s); + runner.context().get_executor(), logger, "http://localhost", + MakeHttpProperties(), std::nullopt, 1s); synchronizer.Close(); // Act: call Next on an already-closed synchronizer. @@ -193,9 +187,8 @@ TEST(FDv2StreamingSynchronizerTest, CloseDuringPendingNextResolvesShutdown) { IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - MakeEndpoints("http://localhost"), MakeHttpProperties(), std::nullopt, - 1s); + runner.context().get_executor(), logger, "http://localhost", + MakeHttpProperties(), std::nullopt, 1s); // Skip the SSE setup; we want Next to be pending purely on the // close/timeout race, not on real network activity. @@ -222,9 +215,8 @@ TEST(FDv2StreamingSynchronizerTest, OnConnectEmptySelectorNoBasisParam) { IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - MakeEndpoints("https://stream.example.com"), MakeHttpProperties(), - std::nullopt, 1s); + runner.context().get_executor(), logger, "https://stream.example.com", + MakeHttpProperties(), std::nullopt, 1s); boost::urls::url base = boost::urls::parse_uri("https://stream.example.com").value(); @@ -247,9 +239,8 @@ TEST(FDv2StreamingSynchronizerTest, OnConnectWithSelectorAppendsBasis) { IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - MakeEndpoints("https://stream.example.com"), MakeHttpProperties(), - std::nullopt, 1s); + runner.context().get_executor(), logger, "https://stream.example.com", + MakeHttpProperties(), std::nullopt, 1s); boost::urls::url base = boost::urls::parse_uri("https://stream.example.com").value(); @@ -274,9 +265,8 @@ TEST(FDv2StreamingSynchronizerTest, OnConnectWithFilterKeyAppendsFilter) { IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - MakeEndpoints("https://stream.example.com"), MakeHttpProperties(), - std::string("my-filter"), 1s); + runner.context().get_executor(), logger, "https://stream.example.com", + MakeHttpProperties(), std::string("my-filter"), 1s); boost::urls::url base = boost::urls::parse_uri("https://stream.example.com").value(); @@ -322,9 +312,8 @@ TEST(FDv2StreamingSynchronizerTest, OnConnectReconnectUsesLatestSelector) { IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - MakeEndpoints("https://stream.example.com"), MakeHttpProperties(), - std::nullopt, 1s); + runner.context().get_executor(), logger, "https://stream.example.com", + MakeHttpProperties(), std::nullopt, 1s); boost::urls::url base = boost::urls::parse_uri("https://stream.example.com").value(); @@ -360,9 +349,8 @@ TEST(FDv2StreamingSynchronizerTest, OnConnectSelectorStateIsPercentEncoded) { IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - MakeEndpoints("https://stream.example.com"), MakeHttpProperties(), - std::nullopt, 1s); + runner.context().get_executor(), logger, "https://stream.example.com", + MakeHttpProperties(), std::nullopt, 1s); boost::urls::url base = boost::urls::parse_uri("https://stream.example.com").value(); @@ -392,9 +380,8 @@ TEST(FDv2StreamingSynchronizerTest, FullChangesetEventsReturnsChangeSet) { IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - MakeEndpoints("http://localhost"), MakeHttpProperties(), std::nullopt, - 1s); + runner.context().get_executor(), logger, "http://localhost", + MakeHttpProperties(), std::nullopt, 1s); FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); sse::Event server_intent("server-intent", @@ -430,9 +417,8 @@ TEST(FDv2StreamingSynchronizerTest, GoodbyeEventReturnsGoodbye) { IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - MakeEndpoints("http://localhost"), MakeHttpProperties(), std::nullopt, - 1s); + runner.context().get_executor(), logger, "http://localhost", + MakeHttpProperties(), std::nullopt, 1s); FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); sse::Event goodbye("goodbye", R"({"reason":"bye"})"); @@ -456,9 +442,8 @@ TEST(FDv2StreamingSynchronizerTest, GoodbyeEventTriggersAsyncRestart) { IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - MakeEndpoints("http://localhost"), MakeHttpProperties(), std::nullopt, - 1s); + runner.context().get_executor(), logger, "http://localhost", + MakeHttpProperties(), std::nullopt, 1s); FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); auto mock_client = std::make_shared(); @@ -486,9 +471,8 @@ TEST(FDv2StreamingSynchronizerTest, IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - MakeEndpoints("http://localhost"), MakeHttpProperties(), std::nullopt, - 1s); + runner.context().get_executor(), logger, "http://localhost", + MakeHttpProperties(), std::nullopt, 1s); FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); // Begin accumulating a payload that we'll abandon mid-flight via Goodbye. @@ -547,9 +531,8 @@ TEST(FDv2StreamingSynchronizerTest, ServerErrorEventReturnsInterrupted) { IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - MakeEndpoints("http://localhost"), MakeHttpProperties(), std::nullopt, - 1s); + runner.context().get_executor(), logger, "http://localhost", + MakeHttpProperties(), std::nullopt, 1s); FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); sse::Event server_error("error", R"({"id":"abc","reason":"oops"})"); @@ -599,9 +582,8 @@ TEST(FDv2StreamingSynchronizerTest, MalformedJsonEventReturnsInterrupted) { IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - MakeEndpoints("http://localhost"), MakeHttpProperties(), std::nullopt, - 1s); + runner.context().get_executor(), logger, "http://localhost", + MakeHttpProperties(), std::nullopt, 1s); FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); auto mock_client = std::make_shared(); @@ -659,9 +641,8 @@ TEST(FDv2StreamingSynchronizerTest, TranslationFailureReturnsInterrupted) { IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - MakeEndpoints("http://localhost"), MakeHttpProperties(), std::nullopt, - 1s); + runner.context().get_executor(), logger, "http://localhost", + MakeHttpProperties(), std::nullopt, 1s); FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); // A non-empty segment object missing required fields triggers a schema @@ -705,9 +686,8 @@ TEST(FDv2StreamingSynchronizerTest, IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - MakeEndpoints("http://localhost"), MakeHttpProperties(), std::nullopt, - 1s); + runner.context().get_executor(), logger, "http://localhost", + MakeHttpProperties(), std::nullopt, 1s); FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); sse::Error error{sse::errors::UnrecoverableClientError{ @@ -734,9 +714,8 @@ TEST(FDv2StreamingSynchronizerTest, RecoverableReadTimeoutReturnsInterrupted) { IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - MakeEndpoints("http://localhost"), MakeHttpProperties(), std::nullopt, - 1s); + runner.context().get_executor(), logger, "http://localhost", + MakeHttpProperties(), std::nullopt, 1s); FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); sse::Error error{sse::errors::ReadTimeout{std::chrono::milliseconds(100)}}; @@ -766,9 +745,8 @@ TEST(FDv2StreamingSynchronizerTest, OnResponseDirectivePropagatesToChangeSet) { IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - MakeEndpoints("http://localhost"), MakeHttpProperties(), std::nullopt, - 1s); + runner.context().get_executor(), logger, "http://localhost", + MakeHttpProperties(), std::nullopt, 1s); FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); boost::beast::http::response_header<> headers; @@ -804,9 +782,8 @@ TEST(FDv2StreamingSynchronizerTest, SecondResponseWithoutDirectiveClearsFlag) { IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - MakeEndpoints("http://localhost"), MakeHttpProperties(), std::nullopt, - 1s); + runner.context().get_executor(), logger, "http://localhost", + MakeHttpProperties(), std::nullopt, 1s); FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); boost::beast::http::response_header<> first; @@ -833,9 +810,8 @@ TEST(FDv2StreamingSynchronizerTest, ErrorAfterDirectiveCarriesFlag) { IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - MakeEndpoints("http://localhost"), MakeHttpProperties(), std::nullopt, - 1s); + runner.context().get_executor(), logger, "http://localhost", + MakeHttpProperties(), std::nullopt, 1s); FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); boost::beast::http::response_header<> headers; From 62709ec08ab38272548e7aa25fc2b62647857315 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Wed, 3 Jun 2026 21:58:21 -0700 Subject: [PATCH 08/30] test: update FDv2 URL tests to match refactored signatures --- .../tests/fdv2_polling_impl_test.cpp | 28 ++++++++----------- .../fdv2_streaming_synchronizer_test.cpp | 15 ++++------ 2 files changed, 18 insertions(+), 25 deletions(-) diff --git a/libs/server-sdk/tests/fdv2_polling_impl_test.cpp b/libs/server-sdk/tests/fdv2_polling_impl_test.cpp index c9ed53da5..f3f77fd4c 100644 --- a/libs/server-sdk/tests/fdv2_polling_impl_test.cpp +++ b/libs/server-sdk/tests/fdv2_polling_impl_test.cpp @@ -111,44 +111,40 @@ TEST(HandleFDv2PollResponseTest, NetworkErrorDoesNotSetFlag) { TEST(MakeFDv2PollRequestTest, BaseWithTrailingSlashDoesNotProduceDoubleSlash) { auto logger = MakeNullLogger(); - config::shared::built::ServiceEndpoints endpoints{"http://example.com/", "", - ""}; auto props = config::shared::Defaults::HttpProperties(); - auto req = MakeFDv2PollRequest(endpoints, props, data_model::Selector{}, - std::nullopt, logger); + auto req = + MakeFDv2PollRequest("http://example.com/", props, + data_model::Selector{}, std::nullopt, logger); EXPECT_EQ(req.Url(), "http://example.com/sdk/poll"); } TEST(MakeFDv2PollRequestTest, BaseWithSubpathTrailingSlashJoinsCleanly) { auto logger = MakeNullLogger(); - config::shared::built::ServiceEndpoints endpoints{ - "http://example.com/relay/", "", ""}; auto props = config::shared::Defaults::HttpProperties(); - auto req = MakeFDv2PollRequest(endpoints, props, data_model::Selector{}, - std::nullopt, logger); + auto req = + MakeFDv2PollRequest("http://example.com/relay/", props, + data_model::Selector{}, std::nullopt, logger); EXPECT_EQ(req.Url(), "http://example.com/relay/sdk/poll"); } TEST(MakeFDv2PollRequestTest, ValidFilterKeyIsIncluded) { auto logger = MakeNullLogger(); - config::shared::built::ServiceEndpoints endpoints{"http://example.com", "", - ""}; auto props = config::shared::Defaults::HttpProperties(); - auto req = MakeFDv2PollRequest(endpoints, props, data_model::Selector{}, - std::string{"my-filter_1.0"}, logger); + auto req = + MakeFDv2PollRequest("http://example.com", props, data_model::Selector{}, + std::string{"my-filter_1.0"}, logger); EXPECT_EQ(req.Url(), "http://example.com/sdk/poll?filter=my-filter_1.0"); } TEST(MakeFDv2PollRequestTest, InvalidFilterKeyIsDropped) { auto logger = MakeNullLogger(); - config::shared::built::ServiceEndpoints endpoints{"http://example.com", "", - ""}; auto props = config::shared::Defaults::HttpProperties(); - auto req = MakeFDv2PollRequest(endpoints, props, data_model::Selector{}, - std::string{"has spaces"}, logger); + auto req = + MakeFDv2PollRequest("http://example.com", props, data_model::Selector{}, + std::string{"has spaces"}, logger); EXPECT_EQ(req.Url(), "http://example.com/sdk/poll"); } diff --git a/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp b/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp index ed748bf31..9b9c16285 100644 --- a/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp +++ b/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp @@ -289,9 +289,8 @@ TEST(FDv2StreamingSynchronizerTest, OnConnectInvalidFilterKeyIsDropped) { IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - MakeEndpoints("https://stream.example.com"), MakeHttpProperties(), - std::string("has spaces"), 1s); + runner.context().get_executor(), logger, "https://stream.example.com", + MakeHttpProperties(), std::string("has spaces"), 1s); boost::urls::url base = boost::urls::parse_uri("https://stream.example.com").value(); @@ -562,9 +561,8 @@ TEST(FDv2StreamingSynchronizerTest, UnknownEventWithGarbageBodyIsIgnored) { IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - MakeEndpoints("http://localhost"), MakeHttpProperties(), std::nullopt, - 1s); + runner.context().get_executor(), logger, "http://localhost", + MakeHttpProperties(), std::nullopt, 1s); FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); auto mock_client = std::make_shared(); @@ -614,9 +612,8 @@ TEST(FDv2StreamingSynchronizerTest, IoContextRunner runner; FDv2StreamingSynchronizer synchronizer( - runner.context().get_executor(), logger, - MakeEndpoints("http://localhost"), MakeHttpProperties(), std::nullopt, - 1s); + runner.context().get_executor(), logger, "http://localhost", + MakeHttpProperties(), std::nullopt, 1s); FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); auto mock_client = std::make_shared(); From a39b086433bb8264756f6d4897f1c152a9928ec8 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 4 Jun 2026 12:59:13 -0700 Subject: [PATCH 09/30] refactor: split FDv1 and FDv2 streaming/polling configs --- .../shared/builders/data_source_builder.hpp | 32 ---------- .../shared/built/data_source_config.hpp | 5 +- .../builders/data_system/fdv2_builder.hpp | 58 ++++++++++++++----- .../config/built/data_system/fdv2_config.hpp | 37 ++++++++++-- libs/server-sdk/src/client_impl.cpp | 38 ++++++------ .../config/builders/data_system/defaults.hpp | 23 +++++--- .../builders/data_system/fdv2_builder.cpp | 44 +++++++++++++- .../fdv2/synchronizer_factories.cpp | 6 +- .../fdv2/synchronizer_factories.hpp | 8 +-- libs/server-sdk/tests/config_builder_test.cpp | 19 +++--- 10 files changed, 172 insertions(+), 98 deletions(-) diff --git a/libs/common/include/launchdarkly/config/shared/builders/data_source_builder.hpp b/libs/common/include/launchdarkly/config/shared/builders/data_source_builder.hpp index 3755091b6..a69bac6e1 100644 --- a/libs/common/include/launchdarkly/config/shared/builders/data_source_builder.hpp +++ b/libs/common/include/launchdarkly/config/shared/builders/data_source_builder.hpp @@ -69,22 +69,6 @@ class StreamingBuilder { return *this; } - /** - * Overrides the streaming base URL for this specific source. When unset, - * the synchronizer uses the top-level ServiceEndpoints streaming URL. - * Intended for FDv2 configurations where individual synchronizers may - * target different endpoints. - * - * @param base_url The base URL to use for this source. - * @return Reference to this builder. - */ - template - std::enable_if_t::value, StreamingBuilder&> BaseUrl( - std::string base_url) { - config_.base_url_override = std::move(base_url); - return *this; - } - /** * Build the streaming config. Used internal to the SDK. * @return The built config. @@ -135,22 +119,6 @@ class PollingBuilder { return *this; } - /** - * Overrides the polling base URL for this specific source. When unset, - * the synchronizer uses the top-level ServiceEndpoints polling URL. - * Intended for FDv2 configurations where individual synchronizers may - * target different endpoints. - * - * @param base_url The base URL to use for this source. - * @return Reference to this builder. - */ - template - std::enable_if_t::value, PollingBuilder&> BaseUrl( - std::string base_url) { - config_.base_url_override = std::move(base_url); - return *this; - } - /** * Build the polling config. Used internal to the SDK. * @return The built config. diff --git a/libs/common/include/launchdarkly/config/shared/built/data_source_config.hpp b/libs/common/include/launchdarkly/config/shared/built/data_source_config.hpp index 377f618e1..32b9cc5b2 100644 --- a/libs/common/include/launchdarkly/config/shared/built/data_source_config.hpp +++ b/libs/common/include/launchdarkly/config/shared/built/data_source_config.hpp @@ -23,15 +23,13 @@ struct StreamingConfig { std::chrono::milliseconds initial_reconnect_delay; std::string streaming_path; std::optional filter_key; - std::optional base_url_override; }; inline bool operator==(StreamingConfig const& lhs, StreamingConfig const& rhs) { return lhs.initial_reconnect_delay == rhs.initial_reconnect_delay && lhs.streaming_path == rhs.streaming_path && - lhs.filter_key == rhs.filter_key && - lhs.base_url_override == rhs.base_url_override; + lhs.filter_key == rhs.filter_key; } template @@ -51,7 +49,6 @@ struct PollingConfig { std::string polling_get_path; std::chrono::seconds min_polling_interval; std::optional filter_key; - std::optional base_url_override; }; template diff --git a/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp b/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp index 4527ba73e..806189a69 100644 --- a/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp +++ b/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp @@ -5,15 +5,38 @@ #include #include +#include +#include namespace launchdarkly::server_side::config::builders { class FDv2Builder { public: - using Streaming = launchdarkly::config::shared::builders::StreamingBuilder< - launchdarkly::config::shared::ServerSDK>; - using Polling = launchdarkly::config::shared::builders::PollingBuilder< - launchdarkly::config::shared::ServerSDK>; + class Streaming { + public: + Streaming& InitialReconnectDelay(std::chrono::milliseconds delay); + Streaming& Filter(std::string filter_key); + Streaming& BaseUrl(std::string base_url); + [[nodiscard]] built::FDv2Config::StreamingConfig Build() const; + + private: + std::chrono::milliseconds initial_reconnect_delay_{1000}; + std::optional filter_key_; + std::optional base_url_override_; + }; + + class Polling { + public: + Polling& PollInterval(std::chrono::seconds interval); + Polling& Filter(std::string filter_key); + Polling& BaseUrl(std::string base_url); + [[nodiscard]] built::FDv2Config::PollingConfig Build() const; + + private: + std::chrono::seconds poll_interval_{30}; + std::optional filter_key_; + std::optional base_url_override_; + }; FDv2Builder(); @@ -46,26 +69,35 @@ class FDv2Builder { */ FDv2Builder& Synchronizer(Polling source); + using FDv1Streaming = + launchdarkly::config::shared::builders::StreamingBuilder< + launchdarkly::config::shared::ServerSDK>; + using FDv1Polling = launchdarkly::config::shared::builders::PollingBuilder< + launchdarkly::config::shared::ServerSDK>; + /** * @brief Configures the FDv1 streaming source used as a last-resort * fallback when the LaunchDarkly service signals (via the - * X-LD-FD-Fallback header) that the SDK should switch to FDv1. - * Enabled by default with standard streaming settings. - * @param source Streaming source configuration to use for the FDv1 - * fallback connection. + * X-LD-FD-Fallback header) that the SDK should switch to FDv1. The + * fallback reads its endpoint from the top-level ServiceEndpoints; to + * point the fallback at a custom URL, configure ServiceEndpoints + * accordingly. + * @param source FDv1 streaming source configuration. * @return Reference to this. */ - FDv2Builder& FDv1Fallback(Streaming source); + FDv2Builder& FDv1Fallback(FDv1Streaming source); /** * @brief Configures the FDv1 polling source used as a last-resort * fallback when the LaunchDarkly service signals (via the - * X-LD-FD-Fallback header) that the SDK should switch to FDv1. - * @param source Polling source configuration to use for the FDv1 - * fallback connection. + * X-LD-FD-Fallback header) that the SDK should switch to FDv1. The + * fallback reads its endpoint from the top-level ServiceEndpoints; to + * point the fallback at a custom URL, configure ServiceEndpoints + * accordingly. + * @param source FDv1 polling source configuration. * @return Reference to this. */ - FDv2Builder& FDv1Fallback(Polling source); + FDv2Builder& FDv1Fallback(FDv1Polling source); /** * @brief Disables the FDv1 fallback. After this call, an FDv1 diff --git a/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/fdv2_config.hpp b/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/fdv2_config.hpp index 4edf7e45f..f0771e2f6 100644 --- a/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/fdv2_config.hpp +++ b/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/fdv2_config.hpp @@ -5,21 +5,50 @@ #include #include +#include #include #include namespace launchdarkly::server_side::config::built { struct FDv2Config { - using StreamingConfig = + struct StreamingConfig { + std::chrono::milliseconds initial_reconnect_delay; + std::optional filter_key; + std::optional base_url_override; + + friend bool operator==(StreamingConfig const& lhs, + StreamingConfig const& rhs) { + return lhs.initial_reconnect_delay == rhs.initial_reconnect_delay && + lhs.filter_key == rhs.filter_key && + lhs.base_url_override == rhs.base_url_override; + } + }; + + struct PollingConfig { + std::chrono::seconds poll_interval; + std::optional filter_key; + std::optional base_url_override; + + friend bool operator==(PollingConfig const& lhs, + PollingConfig const& rhs) { + return lhs.poll_interval == rhs.poll_interval && + lhs.filter_key == rhs.filter_key && + lhs.base_url_override == rhs.base_url_override; + } + }; + + using FDv1StreamingConfig = launchdarkly::config::shared::built::StreamingConfig< launchdarkly::config::shared::ServerSDK>; - using PollingConfig = launchdarkly::config::shared::built::PollingConfig< - launchdarkly::config::shared::ServerSDK>; + using FDv1PollingConfig = + launchdarkly::config::shared::built::PollingConfig< + launchdarkly::config::shared::ServerSDK>; std::vector initializers; std::vector> synchronizers; - std::optional> fdv1_fallback; + std::optional> + fdv1_fallback; std::chrono::milliseconds fallback_timeout; std::chrono::milliseconds recovery_timeout; }; diff --git a/libs/server-sdk/src/client_impl.cpp b/libs/server-sdk/src/client_impl.cpp index 048f062d6..8bc91b61f 100644 --- a/libs/server-sdk/src/client_impl.cpp +++ b/libs/server-sdk/src/client_impl.cpp @@ -123,25 +123,25 @@ static std::unique_ptr MakeFDv2System( sync); } if (cfg.fdv1_fallback) { - std::visit( - overloaded{ - [&](config::built::FDv2Config::StreamingConfig const& - streaming) { - synchronizer_factories.push_back( - std::make_unique< - data_systems::FDv1StreamingAdapterFactory>( - executor, logger, &status_manager, endpoints, - streaming, http_properties)); - }, - [&](config::built::FDv2Config::PollingConfig const& polling) { - synchronizer_factories.push_back( - std::make_unique< - data_systems::FDv1PollingAdapterFactory>( - executor, logger, &status_manager, endpoints, - polling, http_properties)); - }, - }, - *cfg.fdv1_fallback); + std::visit(overloaded{ + [&](config::built::FDv2Config::FDv1StreamingConfig const& + streaming) { + synchronizer_factories.push_back( + std::make_unique< + data_systems::FDv1StreamingAdapterFactory>( + executor, logger, &status_manager, endpoints, + streaming, http_properties)); + }, + [&](config::built::FDv2Config::FDv1PollingConfig const& + polling) { + synchronizer_factories.push_back( + std::make_unique< + data_systems::FDv1PollingAdapterFactory>( + executor, logger, &status_manager, endpoints, + polling, http_properties)); + }, + }, + *cfg.fdv1_fallback); } auto fallback_cond_factory = diff --git a/libs/server-sdk/src/config/builders/data_system/defaults.hpp b/libs/server-sdk/src/config/builders/data_system/defaults.hpp index b97383d0c..2dbaa7487 100644 --- a/libs/server-sdk/src/config/builders/data_system/defaults.hpp +++ b/libs/server-sdk/src/config/builders/data_system/defaults.hpp @@ -1,4 +1,5 @@ #pragma once +#include #include #include #include @@ -36,24 +37,28 @@ struct Defaults { } static auto FDv2StreamingConfig() -> built::FDv2Config::StreamingConfig { - return {std::chrono::seconds{1}, "/all"}; + return {std::chrono::seconds{1}}; } static auto FDv2PollingConfig() -> built::FDv2Config::PollingConfig { - return {std::chrono::seconds{30}, "/sdk/latest-all", - std::chrono::seconds{30}}; + return {std::chrono::seconds{30}}; } static auto FDv2Config() -> built::FDv2Config { using StreamingConfig = built::FDv2Config::StreamingConfig; using PollingConfig = built::FDv2Config::PollingConfig; using SyncVariant = std::variant; - return {{FDv2PollingConfig()}, - std::vector{FDv2StreamingConfig(), - FDv2PollingConfig()}, - SyncVariant{FDv2StreamingConfig()}, - std::chrono::minutes{2}, - std::chrono::minutes{5}}; + using FallbackVariant = + std::variant; + return { + {FDv2PollingConfig()}, + std::vector{FDv2StreamingConfig(), + FDv2PollingConfig()}, + FallbackVariant{launchdarkly::config::shared::Defaults< + launchdarkly::config::shared::ServerSDK>::StreamingConfig()}, + std::chrono::minutes{2}, + std::chrono::minutes{5}}; } static auto DataSystemConfig() -> built::DataSystemConfig { diff --git a/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp b/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp index e54573d5a..b58a2582e 100644 --- a/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp +++ b/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp @@ -4,6 +4,46 @@ namespace launchdarkly::server_side::config::builders { +FDv2Builder::Streaming& FDv2Builder::Streaming::InitialReconnectDelay( + std::chrono::milliseconds delay) { + initial_reconnect_delay_ = delay; + return *this; +} + +FDv2Builder::Streaming& FDv2Builder::Streaming::Filter(std::string filter_key) { + filter_key_ = std::move(filter_key); + return *this; +} + +FDv2Builder::Streaming& FDv2Builder::Streaming::BaseUrl(std::string base_url) { + base_url_override_ = std::move(base_url); + return *this; +} + +built::FDv2Config::StreamingConfig FDv2Builder::Streaming::Build() const { + return {initial_reconnect_delay_, filter_key_, base_url_override_}; +} + +FDv2Builder::Polling& FDv2Builder::Polling::PollInterval( + std::chrono::seconds interval) { + poll_interval_ = interval; + return *this; +} + +FDv2Builder::Polling& FDv2Builder::Polling::Filter(std::string filter_key) { + filter_key_ = std::move(filter_key); + return *this; +} + +FDv2Builder::Polling& FDv2Builder::Polling::BaseUrl(std::string base_url) { + base_url_override_ = std::move(base_url); + return *this; +} + +built::FDv2Config::PollingConfig FDv2Builder::Polling::Build() const { + return {poll_interval_, filter_key_, base_url_override_}; +} + FDv2Builder::FDv2Builder() : config_(Defaults::FDv2Config()), initializers_explicit_(false), @@ -36,12 +76,12 @@ FDv2Builder& FDv2Builder::Synchronizer(Polling source) { return *this; } -FDv2Builder& FDv2Builder::FDv1Fallback(Streaming source) { +FDv2Builder& FDv2Builder::FDv1Fallback(FDv1Streaming source) { config_.fdv1_fallback = source.Build(); return *this; } -FDv2Builder& FDv2Builder::FDv1Fallback(Polling source) { +FDv2Builder& FDv2Builder::FDv1Fallback(FDv1Polling source) { config_.fdv1_fallback = source.Build(); return *this; } diff --git a/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp index 9a09243c4..30036b56c 100644 --- a/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp @@ -6,6 +6,8 @@ #include "polling_synchronizer.hpp" #include "streaming_synchronizer.hpp" +#include + #include namespace launchdarkly::server_side::data_systems { @@ -55,7 +57,7 @@ FDv1StreamingAdapterFactory::FDv1StreamingAdapterFactory( Logger logger, data_components::DataSourceStatusManager* status_manager, config::built::ServiceEndpoints endpoints, - config::built::FDv2Config::StreamingConfig streaming, + config::built::FDv2Config::FDv1StreamingConfig streaming, config::built::HttpProperties http_properties) : executor_(std::move(executor)), logger_(std::move(logger)), @@ -77,7 +79,7 @@ FDv1PollingAdapterFactory::FDv1PollingAdapterFactory( Logger logger, data_components::DataSourceStatusManager* status_manager, config::built::ServiceEndpoints endpoints, - config::built::FDv2Config::PollingConfig polling, + config::built::FDv2Config::FDv1PollingConfig polling, config::built::HttpProperties http_properties) : executor_(std::move(executor)), logger_(std::move(logger)), diff --git a/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.hpp b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.hpp index f15c35fc8..c459733f9 100644 --- a/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.hpp @@ -69,7 +69,7 @@ class FDv1StreamingAdapterFactory final Logger logger, data_components::DataSourceStatusManager* status_manager, config::built::ServiceEndpoints endpoints, - config::built::FDv2Config::StreamingConfig streaming, + config::built::FDv2Config::FDv1StreamingConfig streaming, config::built::HttpProperties http_properties); std::unique_ptr Build() override; @@ -82,7 +82,7 @@ class FDv1StreamingAdapterFactory final // Non-owning. Provided by the orchestrator; must outlive this factory. data_components::DataSourceStatusManager* const status_manager_; config::built::ServiceEndpoints const endpoints_; - config::built::FDv2Config::StreamingConfig const streaming_; + config::built::FDv2Config::FDv1StreamingConfig const streaming_; config::built::HttpProperties const http_properties_; }; @@ -98,7 +98,7 @@ class FDv1PollingAdapterFactory final Logger logger, data_components::DataSourceStatusManager* status_manager, config::built::ServiceEndpoints endpoints, - config::built::FDv2Config::PollingConfig polling, + config::built::FDv2Config::FDv1PollingConfig polling, config::built::HttpProperties http_properties); std::unique_ptr Build() override; @@ -111,7 +111,7 @@ class FDv1PollingAdapterFactory final // Non-owning. Provided by the orchestrator; must outlive this factory. data_components::DataSourceStatusManager* const status_manager_; config::built::ServiceEndpoints const endpoints_; - config::built::FDv2Config::PollingConfig const polling_; + config::built::FDv2Config::FDv1PollingConfig const polling_; config::built::HttpProperties const http_properties_; }; diff --git a/libs/server-sdk/tests/config_builder_test.cpp b/libs/server-sdk/tests/config_builder_test.cpp index 52ac9fdd8..91524537e 100644 --- a/libs/server-sdk/tests/config_builder_test.cpp +++ b/libs/server-sdk/tests/config_builder_test.cpp @@ -127,11 +127,12 @@ TEST_F(ConfigBuilderTest, FDv2_DefaultsAreUsed) { fdv2_config.synchronizers[1])); ASSERT_TRUE(fdv2_config.fdv1_fallback.has_value()); - ASSERT_TRUE(std::holds_alternative( + ASSERT_TRUE(std::holds_alternative( *fdv2_config.fdv1_fallback)); - EXPECT_EQ(std::get( + EXPECT_EQ(std::get( *fdv2_config.fdv1_fallback), - Defaults::FDv2StreamingConfig()); + launchdarkly::config::shared::Defaults< + launchdarkly::config::shared::ServerSDK>::StreamingConfig()); EXPECT_EQ(fdv2_config.fallback_timeout, std::chrono::minutes{2}); EXPECT_EQ(fdv2_config.recovery_timeout, std::chrono::minutes{5}); } @@ -140,7 +141,7 @@ TEST_F(ConfigBuilderTest, FDv2_FDv1FallbackPolling) { ConfigBuilder builder("sdk-123"); builder.DataSystem().Method( builders::DataSystemBuilder::FDv2().FDv1Fallback( - builders::FDv2Builder::Polling().PollInterval( + builders::FDv2Builder::FDv1Polling().PollInterval( std::chrono::seconds{45}))); auto cfg = builder.Build(); @@ -148,12 +149,12 @@ TEST_F(ConfigBuilderTest, FDv2_FDv1FallbackPolling) { std::get(cfg->DataSystemConfig().system_); ASSERT_TRUE(fdv2_config.fdv1_fallback.has_value()); - ASSERT_TRUE(std::holds_alternative( + ASSERT_TRUE(std::holds_alternative( *fdv2_config.fdv1_fallback)); - EXPECT_EQ( - std::get(*fdv2_config.fdv1_fallback) - .poll_interval, - std::chrono::seconds{45}); + EXPECT_EQ(std::get( + *fdv2_config.fdv1_fallback) + .poll_interval, + std::chrono::seconds{45}); } TEST_F(ConfigBuilderTest, FDv2_MultipleSynchronizers) { From 9bb657e25cc273a3d6bd403883719927657b2338 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 4 Jun 2026 14:29:23 -0700 Subject: [PATCH 10/30] refactor: FDv2Builder starts empty; add Default() for spec config --- .../builders/data_system/fdv2_builder.hpp | 25 ++++++++++------- .../builders/data_system/fdv2_builder.cpp | 28 +++++++++---------- libs/server-sdk/tests/config_builder_test.cpp | 2 +- 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp b/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp index 806189a69..8d85d9ab4 100644 --- a/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp +++ b/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp @@ -38,12 +38,22 @@ class FDv2Builder { std::optional base_url_override_; }; + /** + * Constructs a builder with no initializers, no synchronizers, and no + * FDv1 fallback. Use Default() for the spec-recommended configuration. + */ FDv2Builder(); /** - * @brief Appends a polling initializer to the initializers list. The - * first call to this method on a default-constructed builder replaces - * the spec-default initializer list; subsequent calls append. + * @return A builder pre-populated with the spec-recommended initializers, + * synchronizers, and FDv1 fallback. Equivalent to calling + * Initializer(), Synchronizer(), and FDv1Fallback() with the + * standard sources. + */ + static FDv2Builder Default(); + + /** + * @brief Appends a polling initializer to the initializers list. * @param source Polling source configuration for the initializer. * @return Reference to this. */ @@ -52,9 +62,7 @@ class FDv2Builder { /** * @brief Appends a streaming synchronizer to the synchronizers list. * Order in the list determines preference: the first entry is the - * primary synchronizer, subsequent entries are fallbacks. The first - * call to a Synchronizer overload on a default-constructed builder - * replaces the spec-default synchronizer list; subsequent calls append. + * primary synchronizer, subsequent entries are fallbacks. * @param source Streaming source configuration. * @return Reference to this. */ @@ -62,8 +70,7 @@ class FDv2Builder { /** * @brief Appends a polling synchronizer to the synchronizers list. See - * Synchronizer(Streaming) for ordering and default-replacement - * semantics. + * Synchronizer(Streaming) for ordering semantics. * @param source Polling source configuration. * @return Reference to this. */ @@ -130,8 +137,6 @@ class FDv2Builder { private: built::FDv2Config config_; - bool initializers_explicit_; - bool synchronizers_explicit_; }; } // namespace launchdarkly::server_side::config::builders diff --git a/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp b/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp index b58a2582e..430e9d596 100644 --- a/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp +++ b/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp @@ -45,33 +45,31 @@ built::FDv2Config::PollingConfig FDv2Builder::Polling::Build() const { } FDv2Builder::FDv2Builder() - : config_(Defaults::FDv2Config()), - initializers_explicit_(false), - synchronizers_explicit_(false) {} + : config_{{}, + {}, + std::nullopt, + std::chrono::minutes{2}, + std::chrono::minutes{5}} {} + +FDv2Builder FDv2Builder::Default() { + return FDv2Builder() + .Initializer(Polling{}) + .Synchronizer(Streaming{}) + .Synchronizer(Polling{}) + .FDv1Fallback(FDv1Streaming{}); +} FDv2Builder& FDv2Builder::Initializer(Polling source) { - if (!initializers_explicit_) { - config_.initializers.clear(); - initializers_explicit_ = true; - } config_.initializers.push_back(source.Build()); return *this; } FDv2Builder& FDv2Builder::Synchronizer(Streaming source) { - if (!synchronizers_explicit_) { - config_.synchronizers.clear(); - synchronizers_explicit_ = true; - } config_.synchronizers.push_back(source.Build()); return *this; } FDv2Builder& FDv2Builder::Synchronizer(Polling source) { - if (!synchronizers_explicit_) { - config_.synchronizers.clear(); - synchronizers_explicit_ = true; - } config_.synchronizers.push_back(source.Build()); return *this; } diff --git a/libs/server-sdk/tests/config_builder_test.cpp b/libs/server-sdk/tests/config_builder_test.cpp index 91524537e..236898140 100644 --- a/libs/server-sdk/tests/config_builder_test.cpp +++ b/libs/server-sdk/tests/config_builder_test.cpp @@ -104,7 +104,7 @@ TEST_F(ConfigBuilderTest, CanSetPollingPayloadFilterKey) { TEST_F(ConfigBuilderTest, FDv2_DefaultsAreUsed) { ConfigBuilder builder("sdk-123"); - builder.DataSystem().Method(builders::DataSystemBuilder::FDv2()); + builder.DataSystem().Method(builders::DataSystemBuilder::FDv2::Default()); auto cfg = builder.Build(); From f99f1ad29d9ec42d35ebd6ae438f95d058558347 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Sun, 7 Jun 2026 21:55:21 -0700 Subject: [PATCH 11/30] chore: distinguish engaged vs. unconfigured FDv1 fallback in logs --- .../data_systems/fdv2/fdv2_data_system.cpp | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) 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 695b08926..a0425aff8 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 @@ -191,9 +191,16 @@ void FDv2DataSystem::OnInitializerResult( return; } if (result.fdv1_fallback) { - LD_LOG(logger_, LogLevel::kInfo) - << Identity() << ": FDv1 fallback engaged"; source_manager_.SwitchToFDv1Fallback(); + if (source_manager_.AvailableSynchronizerCount() > 0) { + LD_LOG(logger_, LogLevel::kInfo) + << Identity() << ": FDv1 fallback engaged"; + } else { + LD_LOG(logger_, LogLevel::kWarn) + << Identity() + << ": FDv1 fallback directive received; no FDv1 " + "fallback synchronizer configured"; + } got_basis = true; } } @@ -369,9 +376,16 @@ void FDv2DataSystem::OnSynchronizerResult( return; } if (result.fdv1_fallback) { - LD_LOG(logger_, LogLevel::kInfo) - << Identity() << ": FDv1 fallback engaged"; source_manager_.SwitchToFDv1Fallback(); + if (source_manager_.AvailableSynchronizerCount() > 0) { + LD_LOG(logger_, LogLevel::kInfo) + << Identity() << ": FDv1 fallback engaged"; + } else { + LD_LOG(logger_, LogLevel::kWarn) + << Identity() + << ": FDv1 fallback directive received; no FDv1 " + "fallback synchronizer configured"; + } active_synchronizer_.reset(); active_conditions_.reset(); advance = true; From 82d2730720700f8b66671a9bb006ec27b2cc26d4 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Sun, 7 Jun 2026 22:03:36 -0700 Subject: [PATCH 12/30] fix: ignore FDv1 fallback directive when FDv1 source is active --- .../data_systems/fdv2/fdv2_data_system.cpp | 3 +- .../tests/fdv2_data_system_test.cpp | 51 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) 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 a0425aff8..58d5b6f3b 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 @@ -375,7 +375,8 @@ void FDv2DataSystem::OnSynchronizerResult( active_conditions_.reset(); return; } - if (result.fdv1_fallback) { + if (result.fdv1_fallback && + !source_manager_.IsCurrentSynchronizerFDv1Fallback()) { source_manager_.SwitchToFDv1Fallback(); if (source_manager_.AvailableSynchronizerCount() > 0) { LD_LOG(logger_, LogLevel::kInfo) diff --git a/libs/server-sdk/tests/fdv2_data_system_test.cpp b/libs/server-sdk/tests/fdv2_data_system_test.cpp index 3170fcb7d..486225592 100644 --- a/libs/server-sdk/tests/fdv2_data_system_test.cpp +++ b/libs/server-sdk/tests/fdv2_data_system_test.cpp @@ -1203,6 +1203,57 @@ TEST(FDv2DataSystemTest, InitializerFdv1FlagSwitchesToFdv1Adapter) { EXPECT_EQ(1, fdv1_factory_ptr->build_count_); } +TEST(FDv2DataSystemTest, FDv1SourceSelfDirectiveDoesNotRebuildFDv1) { + auto logger = MakeNullLogger(); + boost::asio::io_context ioc; + data_components::DataSourceStatusManager status_manager; + + // FDv2 source emits a ChangeSet with the directive, switching to the + // FDv1 fallback. + auto fdv2_sync = + std::make_unique(std::vector{[]() { + FDv2SourceResult r{FDv2SourceResult::ChangeSet{ + data_model::ChangeSet{ + data_model::ChangeSetType::kNone, + {}, + data_model::Selector{}}}}; + r.fdv1_fallback = true; + return r; + }()}); + auto fdv2_factory = + std::make_unique(std::move(fdv2_sync)); + + // FDv1 source then emits a result also carrying the directive. Once + // FDv1 is active, the directive is silently ignored and the source is + // not rebuilt. + auto fdv1_sync = + std::make_unique(std::vector{[]() { + FDv2SourceResult r{ + FDv2SourceResult::Interrupted{FDv2SourceResult::ErrorInfo{ + FDv2SourceResult::ErrorInfo::ErrorKind::kErrorResponse, + /*status_code=*/418, "self-trigger", + std::chrono::system_clock::now()}}}; + r.fdv1_fallback = true; + return r; + }()}); + auto fdv1_factory = + std::make_unique(std::move(fdv1_sync)); + auto* fdv1_factory_ptr = fdv1_factory.get(); + + std::vector> synchronizers; + synchronizers.push_back(std::move(fdv2_factory)); + synchronizers.push_back(std::move(fdv1_factory)); + + FDv2DataSystem ds({}, std::move(synchronizers), + /*fallback_condition_factory=*/nullptr, + /*recovery_condition_factory=*/nullptr, + ioc.get_executor(), &status_manager, logger); + ds.Initialize(); + ioc.run(); + + EXPECT_EQ(1, fdv1_factory_ptr->build_count_); +} + // ============================================================================ // Destruction protocol: in-flight orchestration // ============================================================================ From c164913240f1a91b5ed2ce838d9ac768596330ca Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Mon, 8 Jun 2026 09:55:25 -0700 Subject: [PATCH 13/30] test: cover initializer ChangeSet+directive basis preservation --- .../tests/fdv2_data_system_test.cpp | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/libs/server-sdk/tests/fdv2_data_system_test.cpp b/libs/server-sdk/tests/fdv2_data_system_test.cpp index 486225592..12d9150eb 100644 --- a/libs/server-sdk/tests/fdv2_data_system_test.cpp +++ b/libs/server-sdk/tests/fdv2_data_system_test.cpp @@ -1203,6 +1203,67 @@ TEST(FDv2DataSystemTest, InitializerFdv1FlagSwitchesToFdv1Adapter) { EXPECT_EQ(1, fdv1_factory_ptr->build_count_); } +TEST(FDv2DataSystemTest, + InitializerChangeSetWithDirectiveAppliesBasisThenSwitches) { + auto logger = MakeNullLogger(); + boost::asio::io_context ioc; + data_components::DataSourceStatusManager status_manager; + + // Initializer returns a Full changeset carrying a flag AND the directive. + // The basis must be applied to the store before the orchestrator + // transitions to the FDv1 fallback. + data_model::Flag flag_a; + flag_a.key = "flagA"; + flag_a.version = 1; + + FDv2SourceResult init_result = MakeFullChangeSetResult( + ChangeSetData{ + ItemChange{"flagA", data_model::FlagDescriptor(flag_a)}, + }, + MakeSelector(1, "state-1")); + init_result.fdv1_fallback = true; + + auto initializer = + std::make_unique(std::move(init_result)); + + std::vector> initializers; + initializers.push_back( + std::make_unique(std::move(initializer))); + + auto fdv2_sync = + std::make_unique(std::vector{}); + auto fdv2_factory = + std::make_unique(std::move(fdv2_sync)); + auto* fdv2_factory_ptr = fdv2_factory.get(); + + auto fdv1_sync = + std::make_unique(std::vector{}); + auto fdv1_factory = + std::make_unique(std::move(fdv1_sync)); + auto* fdv1_factory_ptr = fdv1_factory.get(); + + std::vector> synchronizers; + synchronizers.push_back(std::move(fdv2_factory)); + synchronizers.push_back(std::move(fdv1_factory)); + + FDv2DataSystem ds(std::move(initializers), std::move(synchronizers), + /*fallback_condition_factory=*/nullptr, + /*recovery_condition_factory=*/nullptr, + ioc.get_executor(), &status_manager, logger); + ds.Initialize(); + ioc.run(); + + // Basis applied before the switch. + EXPECT_TRUE(ds.Initialized()); + auto fetched = ds.GetFlag("flagA"); + ASSERT_TRUE(fetched); + EXPECT_EQ(1u, fetched->version); + + // FDv2 synchronizer skipped; FDv1 adapter built and ran. + EXPECT_EQ(0, fdv2_factory_ptr->build_count_); + EXPECT_EQ(1, fdv1_factory_ptr->build_count_); +} + TEST(FDv2DataSystemTest, FDv1SourceSelfDirectiveDoesNotRebuildFDv1) { auto logger = MakeNullLogger(); boost::asio::io_context ioc; From 44f47f4dac873ca1d2faf8b6c0d00960bfefbb71 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Mon, 8 Jun 2026 14:02:17 -0700 Subject: [PATCH 14/30] feat: parse X-LD-FD-Fallback-TTL header into FDv2SourceResult --- .../source/fdv2_source_result.hpp | 44 +++++++++++++- .../data_systems/fdv2/fdv2_polling_impl.cpp | 21 +++++-- .../fdv2/streaming_synchronizer.cpp | 17 +++++- .../fdv2/streaming_synchronizer.hpp | 3 +- .../tests/fdv2_data_system_test.cpp | 12 ++-- .../tests/fdv2_polling_impl_test.cpp | 23 ++++++++ .../fdv2_streaming_synchronizer_test.cpp | 57 +++++++++++++++++++ 7 files changed, 161 insertions(+), 16 deletions(-) diff --git a/libs/server-sdk/src/data_interfaces/source/fdv2_source_result.hpp b/libs/server-sdk/src/data_interfaces/source/fdv2_source_result.hpp index 73329f348..53a0cd442 100644 --- a/libs/server-sdk/src/data_interfaces/source/fdv2_source_result.hpp +++ b/libs/server-sdk/src/data_interfaces/source/fdv2_source_result.hpp @@ -5,12 +5,51 @@ #include #include +#include +#include +#include #include #include +#include +#include #include namespace launchdarkly::server_side::data_interfaces { +/** + * Server-directed instruction to fall back to FDv1, carried alongside any + * FDv2 source result whose underlying transport observed it (e.g. an + * X-LD-FD-Fallback: true response header). + */ +struct FDv1FallbackDirective { + /** Default TTL used when the directive carries no TTL of its own. */ + static constexpr std::chrono::seconds kDefaultTtl = std::chrono::hours(1); + + /** + * Parse the value of an X-LD-FD-Fallback-TTL header (or equivalent + * protocolFallbackTTL field from a goodbye message). Returns + * std::nullopt if the value is malformed. + */ + static std::optional ParseTtl( + std::string_view value) { + std::uint64_t seconds = 0; + auto const* begin = value.data(); + auto const* end = begin + value.size(); + auto const [ptr, ec] = std::from_chars(begin, end, seconds); + if (ec != std::errc{} || ptr != end) { + return std::nullopt; + } + return std::chrono::seconds(seconds); + } + + /** + * How long to stay on FDv1 before attempting to recover to FDv2. + * A value of 0 seconds means the SDK should remain on FDv1 + * indefinitely. + */ + std::chrono::seconds ttl = kDefaultTtl; +}; + /** * Result returned by IFDv2Initializer::Run and IFDv2Synchronizer::Next. */ @@ -56,10 +95,9 @@ struct FDv2SourceResult { Value value; /** - * If true, the server signaled (via the X-LD-FD-Fallback response header) - * that the client should fall back to FDv1. + * Set if the underlying transport observed an FDv1 fallback directive. */ - bool fdv1_fallback = false; + std::optional fdv1_fallback; }; } // namespace launchdarkly::server_side::data_interfaces diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp index 1178aff46..7d18deea5 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp @@ -13,6 +13,7 @@ namespace launchdarkly::server_side::data_systems { static char const* const kFDv1FallbackHeader = "X-LD-FD-Fallback"; +static char const* const kFDv1FallbackTtlHeader = "X-LD-FD-Fallback-TTL"; static char const* const kErrorParsingBody = "Could not parse FDv2 polling response"; @@ -34,10 +35,22 @@ static ErrorInfo MakeError(ErrorKind kind, std::chrono::system_clock::now()}; } -static bool ReadFDv1FallbackDirective( - network::HttpResult::HeadersType const& headers) { +static std::optional +ReadFDv1FallbackDirective(network::HttpResult::HeadersType const& headers) { auto const it = headers.find(kFDv1FallbackHeader); - return it != headers.end() && boost::iequals(it->second, "true"); + if (it == headers.end() || !boost::iequals(it->second, "true")) { + return std::nullopt; + } + data_interfaces::FDv1FallbackDirective directive; + auto const ttl_it = headers.find(kFDv1FallbackTtlHeader); + if (ttl_it != headers.end()) { + auto const ttl = + data_interfaces::FDv1FallbackDirective::ParseTtl(ttl_it->second); + if (ttl) { + directive.ttl = *ttl; + } + } + return directive; } network::HttpRequest MakeFDv2PollRequest( @@ -183,7 +196,7 @@ data_interfaces::FDv2SourceResult HandleFDv2PollResponse( MakeError(ErrorKind::kNetworkError, 0, std::move(error_msg))}}; } - bool const fdv1_fallback = ReadFDv1FallbackDirective(res.Headers()); + auto fdv1_fallback = ReadFDv1FallbackDirective(res.Headers()); if (res.Status() == 304) { return FDv2SourceResult{ diff --git a/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp index c43483fc3..868725630 100644 --- a/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp @@ -187,8 +187,21 @@ void FDv2StreamingSynchronizer::State::OnConnect(HttpRequest* req) { void FDv2StreamingSynchronizer::State::OnResponse( HttpResponseHeader const& headers) { auto const it = headers.find("X-LD-FD-Fallback"); - bool const directive = - it != headers.end() && boost::iequals(it->value(), "true"); + if (it == headers.end() || !boost::iequals(it->value(), "true")) { + std::lock_guard lock(mutex_); + latest_fdv1_fallback_.reset(); + return; + } + data_interfaces::FDv1FallbackDirective directive; + auto const ttl_it = headers.find("X-LD-FD-Fallback-TTL"); + if (ttl_it != headers.end()) { + auto const value = ttl_it->value(); + auto const ttl = data_interfaces::FDv1FallbackDirective::ParseTtl( + std::string_view{value.data(), value.size()}); + if (ttl) { + directive.ttl = *ttl; + } + } std::lock_guard lock(mutex_); latest_fdv1_fallback_ = directive; } diff --git a/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.hpp b/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.hpp index 1abed1f64..4e297212c 100644 --- a/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.hpp @@ -147,7 +147,8 @@ class FDv2StreamingSynchronizer final bool started_ = false; bool closed_ = false; // FDv1 fallback directive from the most recent SSE response. - bool latest_fdv1_fallback_ = false; + std::optional + latest_fdv1_fallback_; data_model::Selector latest_selector_; std::optional base_url_; std::shared_ptr sse_client_; diff --git a/libs/server-sdk/tests/fdv2_data_system_test.cpp b/libs/server-sdk/tests/fdv2_data_system_test.cpp index 12d9150eb..96e2fc33d 100644 --- a/libs/server-sdk/tests/fdv2_data_system_test.cpp +++ b/libs/server-sdk/tests/fdv2_data_system_test.cpp @@ -1097,7 +1097,7 @@ TEST(FDv2DataSystemTest, SynchronizerFdv1FlagSwitchesToFdv1Adapter) { data_model::ChangeSetType::kNone, {}, data_model::Selector{}}}}; - r.fdv1_fallback = true; + r.fdv1_fallback = data_interfaces::FDv1FallbackDirective{}; return r; }()}); auto fdv2_factory = @@ -1136,7 +1136,7 @@ TEST(FDv2DataSystemTest, SynchronizerFdv1FlagWithoutAdapterTransitionsOff) { FDv2SourceResult::ErrorInfo::ErrorKind::kErrorResponse, /*status_code=*/418, "directive", std::chrono::system_clock::now()}}}; - r.fdv1_fallback = true; + r.fdv1_fallback = data_interfaces::FDv1FallbackDirective{}; return r; }()}); auto fdv2_factory = @@ -1167,7 +1167,7 @@ TEST(FDv2DataSystemTest, InitializerFdv1FlagSwitchesToFdv1Adapter) { FDv2SourceResult::ErrorInfo::ErrorKind::kErrorResponse, /*status_code=*/418, "directive", std::chrono::system_clock::now()}}}; - init_result.fdv1_fallback = true; + init_result.fdv1_fallback = data_interfaces::FDv1FallbackDirective{}; auto initializer = std::make_unique(std::move(init_result)); @@ -1221,7 +1221,7 @@ TEST(FDv2DataSystemTest, ItemChange{"flagA", data_model::FlagDescriptor(flag_a)}, }, MakeSelector(1, "state-1")); - init_result.fdv1_fallback = true; + init_result.fdv1_fallback = data_interfaces::FDv1FallbackDirective{}; auto initializer = std::make_unique(std::move(init_result)); @@ -1278,7 +1278,7 @@ TEST(FDv2DataSystemTest, FDv1SourceSelfDirectiveDoesNotRebuildFDv1) { data_model::ChangeSetType::kNone, {}, data_model::Selector{}}}}; - r.fdv1_fallback = true; + r.fdv1_fallback = data_interfaces::FDv1FallbackDirective{}; return r; }()}); auto fdv2_factory = @@ -1294,7 +1294,7 @@ TEST(FDv2DataSystemTest, FDv1SourceSelfDirectiveDoesNotRebuildFDv1) { FDv2SourceResult::ErrorInfo::ErrorKind::kErrorResponse, /*status_code=*/418, "self-trigger", std::chrono::system_clock::now()}}}; - r.fdv1_fallback = true; + r.fdv1_fallback = data_interfaces::FDv1FallbackDirective{}; return r; }()}); auto fdv1_factory = diff --git a/libs/server-sdk/tests/fdv2_polling_impl_test.cpp b/libs/server-sdk/tests/fdv2_polling_impl_test.cpp index c9ed53da5..531a41aa6 100644 --- a/libs/server-sdk/tests/fdv2_polling_impl_test.cpp +++ b/libs/server-sdk/tests/fdv2_polling_impl_test.cpp @@ -98,6 +98,29 @@ TEST(HandleFDv2PollResponseTest, HeaderValueOtherThanTrueDoesNotSetFlag) { EXPECT_FALSE(result.fdv1_fallback); } +TEST(HandleFDv2PollResponseTest, DirectiveWithoutTtlHeaderUsesDefault) { + auto result = + HandleResponse(304, std::nullopt, {{"X-LD-FD-Fallback", "true"}}); + ASSERT_TRUE(result.fdv1_fallback); + EXPECT_EQ(FDv1FallbackDirective::kDefaultTtl, result.fdv1_fallback->ttl); +} + +TEST(HandleFDv2PollResponseTest, DirectiveWithTtlHeaderParsesValue) { + auto result = HandleResponse( + 304, std::nullopt, + {{"X-LD-FD-Fallback", "true"}, {"X-LD-FD-Fallback-TTL", "60"}}); + ASSERT_TRUE(result.fdv1_fallback); + EXPECT_EQ(std::chrono::seconds{60}, result.fdv1_fallback->ttl); +} + +TEST(HandleFDv2PollResponseTest, DirectiveWithMalformedTtlFallsBackToDefault) { + auto result = HandleResponse(304, std::nullopt, + {{"X-LD-FD-Fallback", "true"}, + {"X-LD-FD-Fallback-TTL", "not-a-number"}}); + ASSERT_TRUE(result.fdv1_fallback); + EXPECT_EQ(FDv1FallbackDirective::kDefaultTtl, result.fdv1_fallback->ttl); +} + TEST(HandleFDv2PollResponseTest, NetworkErrorDoesNotSetFlag) { auto logger = MakeNullLogger(); FDv2ProtocolHandler handler; diff --git a/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp b/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp index 68687adb5..be4a1fe3e 100644 --- a/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp +++ b/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp @@ -854,3 +854,60 @@ TEST(FDv2StreamingSynchronizerTest, ErrorAfterDirectiveCarriesFlag) { std::holds_alternative(result->value)); EXPECT_TRUE(result->fdv1_fallback); } + +TEST(FDv2StreamingSynchronizerTest, DirectiveWithTtlHeaderParsesValue) { + auto logger = MakeNullLogger(); + IoContextRunner runner; + + FDv2StreamingSynchronizer synchronizer( + runner.context().get_executor(), logger, + MakeEndpoints("http://localhost"), MakeHttpProperties(), std::nullopt, + 1s); + FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); + + // Server sends the directive with an explicit TTL. + boost::beast::http::response_header<> headers; + headers.result(200); + headers.set("X-LD-FD-Fallback", "true"); + headers.set("X-LD-FD-Fallback-TTL", "60"); + FDv2StreamingSynchronizerTestPeer::OnResponse(synchronizer, headers); + + // Drive the source to surface the directive on its next result. + FDv2StreamingSynchronizerTestPeer::OnError( + synchronizer, + sse::Error{sse::errors::ReadTimeout{std::chrono::milliseconds(0)}}); + auto result = synchronizer.Next(data_model::Selector{}).WaitForResult(2s); + + // The parsed TTL is propagated on the result. + ASSERT_TRUE(result.has_value()); + ASSERT_TRUE(result->fdv1_fallback); + EXPECT_EQ(std::chrono::seconds{60}, result->fdv1_fallback->ttl); +} + +TEST(FDv2StreamingSynchronizerTest, DirectiveWithoutTtlHeaderUsesDefault) { + auto logger = MakeNullLogger(); + IoContextRunner runner; + + FDv2StreamingSynchronizer synchronizer( + runner.context().get_executor(), logger, + MakeEndpoints("http://localhost"), MakeHttpProperties(), std::nullopt, + 1s); + FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); + + // Server sends the directive with no TTL header. + boost::beast::http::response_header<> headers; + headers.result(200); + headers.set("X-LD-FD-Fallback", "true"); + FDv2StreamingSynchronizerTestPeer::OnResponse(synchronizer, headers); + + // Drive the source to surface the directive on its next result. + FDv2StreamingSynchronizerTestPeer::OnError( + synchronizer, + sse::Error{sse::errors::ReadTimeout{std::chrono::milliseconds(0)}}); + auto result = synchronizer.Next(data_model::Selector{}).WaitForResult(2s); + + // The result carries the default TTL. + ASSERT_TRUE(result.has_value()); + ASSERT_TRUE(result->fdv1_fallback); + EXPECT_EQ(FDv1FallbackDirective::kDefaultTtl, result->fdv1_fallback->ttl); +} From 6ca1a4973ee021120e91754bd4ef0bde2a8c6ebf Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Mon, 8 Jun 2026 14:33:05 -0700 Subject: [PATCH 15/30] feat: parse protocolFallbackTTL and retryAfter from goodbye --- .../serialization/json_fdv2_events.hpp | 4 ++++ .../src/serialization/json_fdv2_events.cpp | 3 +++ .../tests/fdv2_serialization_test.cpp | 20 +++++++++++++++++++ .../data_systems/fdv2/fdv2_polling_impl.cpp | 13 ++++++++++-- .../fdv2/streaming_synchronizer.cpp | 16 +++++++++++++-- 5 files changed, 52 insertions(+), 4 deletions(-) diff --git a/libs/internal/include/launchdarkly/serialization/json_fdv2_events.hpp b/libs/internal/include/launchdarkly/serialization/json_fdv2_events.hpp index 50ea455ad..fcf62af4c 100644 --- a/libs/internal/include/launchdarkly/serialization/json_fdv2_events.hpp +++ b/libs/internal/include/launchdarkly/serialization/json_fdv2_events.hpp @@ -45,6 +45,10 @@ struct PayloadTransferred { struct Goodbye { std::optional reason; + // If set, indicates an FDv1 fallback directive with this TTL in seconds. + std::optional protocol_fallback_ttl; + // If set, indicates a Retry-After equivalent in seconds. + std::optional retry_after; }; struct FDv2Error { diff --git a/libs/internal/src/serialization/json_fdv2_events.cpp b/libs/internal/src/serialization/json_fdv2_events.cpp index 63fba0d38..dc14d0205 100644 --- a/libs/internal/src/serialization/json_fdv2_events.cpp +++ b/libs/internal/src/serialization/json_fdv2_events.cpp @@ -132,6 +132,9 @@ tl::expected, JsonError> tag_invoke( Goodbye goodbye{}; PARSE_CONDITIONAL_FIELD(goodbye.reason, obj, "reason"); + PARSE_CONDITIONAL_FIELD(goodbye.protocol_fallback_ttl, obj, + "protocolFallbackTTL"); + PARSE_CONDITIONAL_FIELD(goodbye.retry_after, obj, "retryAfter"); return goodbye; } diff --git a/libs/internal/tests/fdv2_serialization_test.cpp b/libs/internal/tests/fdv2_serialization_test.cpp index 8083fa03b..fa194a103 100644 --- a/libs/internal/tests/fdv2_serialization_test.cpp +++ b/libs/internal/tests/fdv2_serialization_test.cpp @@ -363,6 +363,26 @@ TEST(GoodbyeTests, DeserializesWithoutReason) { ASSERT_FALSE(result.value()->reason); } +TEST(GoodbyeTests, DeserializesWithProtocolFallbackTtl) { + auto result = + boost::json::value_to, JsonError>>( + boost::json::parse(R"({"protocolFallbackTTL":60})")); + ASSERT_TRUE(result); + ASSERT_TRUE(result.value()); + ASSERT_TRUE(result.value()->protocol_fallback_ttl); + ASSERT_EQ(60, *result.value()->protocol_fallback_ttl); +} + +TEST(GoodbyeTests, DeserializesWithRetryAfter) { + auto result = + boost::json::value_to, JsonError>>( + boost::json::parse(R"({"retryAfter":5})")); + ASSERT_TRUE(result); + ASSERT_TRUE(result.value()); + ASSERT_TRUE(result.value()->retry_after); + ASSERT_EQ(5, *result.value()->retry_after); +} + TEST(GoodbyeTests, WrongTypeReturnsSchemaFailure) { auto result = boost::json::value_to, JsonError>>( diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp index 7d18deea5..157eddb99 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp @@ -129,7 +129,12 @@ static FDv2SourceResult ParseFDv2PollEvents( FDv2SourceResult::ChangeSet{std::move(*typed)}}; } if (auto* goodbye = std::get_if(&result)) { - return FDv2SourceResult{FDv2SourceResult::Goodbye{goodbye->reason}}; + FDv2SourceResult result{FDv2SourceResult::Goodbye{goodbye->reason}}; + if (goodbye->protocol_fallback_ttl) { + result.fdv1_fallback = data_interfaces::FDv1FallbackDirective{ + std::chrono::seconds(*goodbye->protocol_fallback_ttl)}; + } + return result; } if (auto* error = std::get_if(&result)) { if (error->kind == FDv2ProtocolHandler::Error::Kind::kServerError) { @@ -228,7 +233,11 @@ data_interfaces::FDv2SourceResult HandleFDv2PollResponse( << identity << ": " << interrupted->error.Message(); } } - result.fdv1_fallback = fdv1_fallback; + // An explicit directive parsed from the response body (e.g. via a + // goodbye event) takes precedence over the HTTP response header. + if (!result.fdv1_fallback) { + result.fdv1_fallback = fdv1_fallback; + } return result; } diff --git a/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp index 868725630..5e658e319 100644 --- a/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/streaming_synchronizer.cpp @@ -253,7 +253,14 @@ void FDv2StreamingSynchronizer::State::OnEvent(sse::Event const& event) { << ": Goodbye was received from the LaunchDarkly " "connection with reason: '" << r.reason.value_or("") << "'."; - Notify(FDv2SourceResult{FDv2SourceResult::Goodbye{r.reason}}); + FDv2SourceResult goodbye_result{ + FDv2SourceResult::Goodbye{r.reason}}; + if (r.protocol_fallback_ttl) { + goodbye_result.fdv1_fallback = + data_interfaces::FDv1FallbackDirective{ + 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. @@ -324,7 +331,12 @@ void FDv2StreamingSynchronizer::State::Notify(FDv2SourceResult result) { std::optional> promise; { std::lock_guard lock(mutex_); - result.fdv1_fallback = latest_fdv1_fallback_; + // An explicit directive on the result (e.g. parsed from a goodbye + // message) takes precedence over the most recent HTTP response + // header. + if (!result.fdv1_fallback) { + result.fdv1_fallback = latest_fdv1_fallback_; + } if (pending_promise_) { promise = std::move(pending_promise_); pending_promise_.reset(); From 8f19a0e5d2ba0ad40ac01528e8a06b75cf18a1cd Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Mon, 8 Jun 2026 16:27:17 -0700 Subject: [PATCH 16/30] feat: schedule FDv2 retry after FDv1 fallback TTL --- .../data_systems/fdv2/fdv2_data_system.cpp | 67 +++++++++- .../data_systems/fdv2/fdv2_data_system.hpp | 13 ++ .../src/data_systems/fdv2/source_manager.cpp | 8 ++ .../src/data_systems/fdv2/source_manager.hpp | 6 + .../tests/fdv2_data_system_test.cpp | 123 ++++++++++++++++-- libs/server-sdk/tests/source_manager_test.cpp | 41 ++++++ 6 files changed, 246 insertions(+), 12 deletions(-) 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 58d5b6f3b..16bc7d8a0 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 @@ -1,6 +1,7 @@ #include "fdv2_data_system.hpp" #include +#include #include @@ -59,6 +60,7 @@ FDv2DataSystem::~FDv2DataSystem() { void FDv2DataSystem::Close() { std::lock_guard lock(mutex_); closed_ = true; + fdv1_fallback_retry_cancel_.Cancel(); if (active_initializer_) { active_initializer_->Close(); } @@ -147,6 +149,7 @@ void FDv2DataSystem::OnInitializerResult( bool got_basis = false; bool got_shutdown = false; + bool disconnected = false; std::visit( overloaded{ @@ -195,16 +198,25 @@ void FDv2DataSystem::OnInitializerResult( if (source_manager_.AvailableSynchronizerCount() > 0) { LD_LOG(logger_, LogLevel::kInfo) << Identity() << ": FDv1 fallback engaged"; + got_basis = true; } else { LD_LOG(logger_, LogLevel::kWarn) << Identity() << ": FDv1 fallback directive received; no FDv1 " "fallback synchronizer configured"; + disconnected = true; } - got_basis = true; + ScheduleFDv2RetryLocked(result.fdv1_fallback->ttl); } } + if (disconnected) { + status_manager_->SetState( + DataSourceStatus::DataSourceState::kInterrupted, + DataSourceStatus::ErrorInfo::ErrorKind::kUnknown, + "FDv1 fallback directive received; no FDv1 fallback configured"); + return; + } if (got_basis) { StartSynchronizers(); } else { @@ -335,6 +347,7 @@ void FDv2DataSystem::OnSynchronizerResult( bool got_shutdown = false; bool advance = false; + bool disconnected = false; std::visit( overloaded{ @@ -378,18 +391,20 @@ void FDv2DataSystem::OnSynchronizerResult( if (result.fdv1_fallback && !source_manager_.IsCurrentSynchronizerFDv1Fallback()) { source_manager_.SwitchToFDv1Fallback(); + active_synchronizer_.reset(); + active_conditions_.reset(); if (source_manager_.AvailableSynchronizerCount() > 0) { LD_LOG(logger_, LogLevel::kInfo) << Identity() << ": FDv1 fallback engaged"; + advance = true; } else { LD_LOG(logger_, LogLevel::kWarn) << Identity() << ": FDv1 fallback directive received; no FDv1 " "fallback synchronizer configured"; + disconnected = true; } - active_synchronizer_.reset(); - active_conditions_.reset(); - advance = true; + ScheduleFDv2RetryLocked(result.fdv1_fallback->ttl); } else if (advance) { source_manager_.BlockCurrentSynchronizer(); active_synchronizer_.reset(); @@ -397,6 +412,13 @@ void FDv2DataSystem::OnSynchronizerResult( } } + if (disconnected) { + status_manager_->SetState( + DataSourceStatus::DataSourceState::kInterrupted, + DataSourceStatus::ErrorInfo::ErrorKind::kUnknown, + "FDv1 fallback directive received; no FDv1 fallback configured"); + return; + } if (advance) { StartSynchronizers(); } else { @@ -404,6 +426,43 @@ void FDv2DataSystem::OnSynchronizerResult( } } +void FDv2DataSystem::ScheduleFDv2RetryLocked(std::chrono::seconds ttl) { + if (ttl == std::chrono::seconds::zero()) { + return; + } + LD_LOG(logger_, LogLevel::kInfo) + << Identity() << ": FDv2 retry scheduled in " << ttl.count() << "s"; + async::Delay(ioc_, ttl, fdv1_fallback_retry_cancel_.GetToken()) + .Then( + [this](bool fired) -> std::monostate { + if (fired) { + OnFDv1RetryTimer(); + } + return {}; + }, + [ioc = ioc_](async::Continuation work) { + boost::asio::post(ioc, std::move(work)); + }); +} + +void FDv2DataSystem::OnFDv1RetryTimer() { + { + std::lock_guard lock(mutex_); + if (closed_) { + return; + } + LD_LOG(logger_, LogLevel::kInfo) + << Identity() << ": FDv2 retry timer fired; re-engaging FDv2"; + source_manager_.SwitchBackToFDv2(); + if (active_synchronizer_) { + active_synchronizer_->Close(); + active_synchronizer_.reset(); + } + active_conditions_.reset(); + } + StartSynchronizers(); +} + void FDv2DataSystem::ApplyChangeSet( data_model::ChangeSet change_set) { if (change_set.selector.value.has_value()) { 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 2eb243140..3c7147ec1 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 @@ -10,12 +10,14 @@ #include "conditions.hpp" #include "source_manager.hpp" +#include #include #include #include #include +#include #include #include #include @@ -247,6 +249,14 @@ class FDv2DataSystem final : public data_interfaces::IDataSystem { void OnSynchronizerResult(data_interfaces::FDv2SourceResult result); void OnConditionFired(data_interfaces::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. + void ScheduleFDv2RetryLocked(std::chrono::seconds ttl); + + // Invoked when the FDv1 fallback TTL expires. Switches the source list + // back to FDv2 and restarts the synchronizer phase. + void OnFDv1RetryTimer(); + // Builds the conditions to apply to the currently active synchronizer. // Must be called with mutex_ held; reads source_manager_ state. std::unique_ptr BuildActiveConditions() const; @@ -291,6 +301,9 @@ class FDv2DataSystem final : public data_interfaces::IDataSystem { std::unique_ptr active_initializer_; std::unique_ptr active_synchronizer_; std::unique_ptr active_conditions_; + + // Cancelled in Close() to abort any pending FDv1 fallback retry delay. + async::CancellationSource fdv1_fallback_retry_cancel_; }; } // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/fdv2/source_manager.cpp b/libs/server-sdk/src/data_systems/fdv2/source_manager.cpp index 4af2405a6..3f020e404 100644 --- a/libs/server-sdk/src/data_systems/fdv2/source_manager.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/source_manager.cpp @@ -54,6 +54,14 @@ void SourceManager::SwitchToFDv1Fallback() { 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) { diff --git a/libs/server-sdk/src/data_systems/fdv2/source_manager.hpp b/libs/server-sdk/src/data_systems/fdv2/source_manager.hpp index bd2347ccf..264dfd494 100644 --- a/libs/server-sdk/src/data_systems/fdv2/source_manager.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/source_manager.hpp @@ -61,6 +61,12 @@ class SourceManager { */ void SwitchToFDv1Fallback(); + /** + * Returns synchronizer state to the initial configuration, including + * unblocking factories previously blocked by terminal errors. + */ + void SwitchBackToFDv2(); + /** * Returns true if the currently tracked factory is the first Available * factory in the list. Returns false if no factory is currently tracked. diff --git a/libs/server-sdk/tests/fdv2_data_system_test.cpp b/libs/server-sdk/tests/fdv2_data_system_test.cpp index 96e2fc33d..e7d170ccd 100644 --- a/libs/server-sdk/tests/fdv2_data_system_test.cpp +++ b/libs/server-sdk/tests/fdv2_data_system_test.cpp @@ -1097,7 +1097,8 @@ TEST(FDv2DataSystemTest, SynchronizerFdv1FlagSwitchesToFdv1Adapter) { data_model::ChangeSetType::kNone, {}, data_model::Selector{}}}}; - r.fdv1_fallback = data_interfaces::FDv1FallbackDirective{}; + r.fdv1_fallback = + data_interfaces::FDv1FallbackDirective{std::chrono::seconds{0}}; return r; }()}); auto fdv2_factory = @@ -1124,11 +1125,13 @@ TEST(FDv2DataSystemTest, SynchronizerFdv1FlagSwitchesToFdv1Adapter) { EXPECT_EQ(1, fdv1_factory_ptr->build_count_); } -TEST(FDv2DataSystemTest, SynchronizerFdv1FlagWithoutAdapterTransitionsOff) { +TEST(FDv2DataSystemTest, + SynchronizerFdv1FlagWithoutAdapterDoesNotTransitionToOff) { auto logger = MakeNullLogger(); boost::asio::io_context ioc; data_components::DataSourceStatusManager status_manager; + // Directive with TTL=0: indefinite, no automatic FDv2 retry. auto fdv2_sync = std::make_unique(std::vector{[]() { FDv2SourceResult r{ @@ -1136,7 +1139,8 @@ TEST(FDv2DataSystemTest, SynchronizerFdv1FlagWithoutAdapterTransitionsOff) { FDv2SourceResult::ErrorInfo::ErrorKind::kErrorResponse, /*status_code=*/418, "directive", std::chrono::system_clock::now()}}}; - r.fdv1_fallback = data_interfaces::FDv1FallbackDirective{}; + r.fdv1_fallback = + data_interfaces::FDv1FallbackDirective{std::chrono::seconds{0}}; return r; }()}); auto fdv2_factory = @@ -1152,7 +1156,7 @@ TEST(FDv2DataSystemTest, SynchronizerFdv1FlagWithoutAdapterTransitionsOff) { ds.Initialize(); ioc.run(); - EXPECT_EQ(DataSourceStatus::DataSourceState::kOff, + EXPECT_NE(DataSourceStatus::DataSourceState::kOff, status_manager.Status().State()); } @@ -1167,7 +1171,8 @@ TEST(FDv2DataSystemTest, InitializerFdv1FlagSwitchesToFdv1Adapter) { FDv2SourceResult::ErrorInfo::ErrorKind::kErrorResponse, /*status_code=*/418, "directive", std::chrono::system_clock::now()}}}; - init_result.fdv1_fallback = data_interfaces::FDv1FallbackDirective{}; + init_result.fdv1_fallback = + data_interfaces::FDv1FallbackDirective{std::chrono::seconds{0}}; auto initializer = std::make_unique(std::move(init_result)); @@ -1221,7 +1226,8 @@ TEST(FDv2DataSystemTest, ItemChange{"flagA", data_model::FlagDescriptor(flag_a)}, }, MakeSelector(1, "state-1")); - init_result.fdv1_fallback = data_interfaces::FDv1FallbackDirective{}; + init_result.fdv1_fallback = + data_interfaces::FDv1FallbackDirective{std::chrono::seconds{0}}; auto initializer = std::make_unique(std::move(init_result)); @@ -1264,6 +1270,105 @@ TEST(FDv2DataSystemTest, EXPECT_EQ(1, fdv1_factory_ptr->build_count_); } +TEST(FDv2DataSystemTest, DirectiveTtlElapseRebuildsFDv2) { + auto logger = MakeNullLogger(); + boost::asio::io_context ioc; + data_components::DataSourceStatusManager status_manager; + + // First build: source emits the directive with a 1s TTL. Second build + // (after the TTL elapses): source returns no results, so MockSynchronizer + // emits Shutdown and orchestration ends. + auto first_sync = + std::make_unique(std::vector{[]() { + FDv2SourceResult r{ + FDv2SourceResult::Interrupted{FDv2SourceResult::ErrorInfo{ + FDv2SourceResult::ErrorInfo::ErrorKind::kErrorResponse, + /*status_code=*/418, "directive", + std::chrono::system_clock::now()}}}; + r.fdv1_fallback = + data_interfaces::FDv1FallbackDirective{std::chrono::seconds{1}}; + return r; + }()}); + auto second_sync = + std::make_unique(std::vector{}); + + std::vector> sources; + sources.push_back(std::move(first_sync)); + sources.push_back(std::move(second_sync)); + auto factory = + std::make_unique(std::move(sources)); + auto* factory_ptr = factory.get(); + + std::vector> synchronizers; + synchronizers.push_back(std::move(factory)); + + FDv2DataSystem ds({}, std::move(synchronizers), + /*fallback_condition_factory=*/nullptr, + /*recovery_condition_factory=*/nullptr, + ioc.get_executor(), &status_manager, logger); + ds.Initialize(); + ioc.run(); + + EXPECT_EQ(2, factory_ptr->build_count_); +} + +TEST(FDv2DataSystemTest, FDv2RecoveryAfterTtlAcceptsValidData) { + auto logger = MakeNullLogger(); + boost::asio::io_context ioc; + data_components::DataSourceStatusManager status_manager; + + // First build: emits directive with 1s TTL. Second build (recovery): + // emits a valid ChangeSet without the directive. + auto first_sync = + std::make_unique(std::vector{[]() { + FDv2SourceResult r{ + FDv2SourceResult::Interrupted{FDv2SourceResult::ErrorInfo{ + FDv2SourceResult::ErrorInfo::ErrorKind::kErrorResponse, + /*status_code=*/418, "directive", + std::chrono::system_clock::now()}}}; + r.fdv1_fallback = + data_interfaces::FDv1FallbackDirective{std::chrono::seconds{1}}; + return r; + }()}); + + data_model::Flag flag_a; + flag_a.key = "flagA"; + flag_a.version = 1; + + auto second_sync = std::make_unique( + std::vector{MakeFullChangeSetResult( + ChangeSetData{ + ItemChange{"flagA", data_model::FlagDescriptor(flag_a)}, + }, + MakeSelector(1, "state-1"))}); + + std::vector> sources; + sources.push_back(std::move(first_sync)); + sources.push_back(std::move(second_sync)); + auto factory = + std::make_unique(std::move(sources)); + auto* factory_ptr = factory.get(); + + std::vector> synchronizers; + synchronizers.push_back(std::move(factory)); + + FDv2DataSystem ds({}, std::move(synchronizers), + /*fallback_condition_factory=*/nullptr, + /*recovery_condition_factory=*/nullptr, + ioc.get_executor(), &status_manager, logger); + ds.Initialize(); + ioc.run(); + + // FDv2 was rebuilt after the TTL elapsed, the new ChangeSet was applied, + // and the data system is in the valid state. + EXPECT_EQ(2, factory_ptr->build_count_); + EXPECT_TRUE(ds.Initialized()); + EXPECT_EQ(DataSourceStatus::DataSourceState::kValid, + status_manager.Status().State()); + auto fetched = ds.GetFlag("flagA"); + ASSERT_TRUE(fetched); +} + TEST(FDv2DataSystemTest, FDv1SourceSelfDirectiveDoesNotRebuildFDv1) { auto logger = MakeNullLogger(); boost::asio::io_context ioc; @@ -1278,7 +1383,8 @@ TEST(FDv2DataSystemTest, FDv1SourceSelfDirectiveDoesNotRebuildFDv1) { data_model::ChangeSetType::kNone, {}, data_model::Selector{}}}}; - r.fdv1_fallback = data_interfaces::FDv1FallbackDirective{}; + r.fdv1_fallback = + data_interfaces::FDv1FallbackDirective{std::chrono::seconds{0}}; return r; }()}); auto fdv2_factory = @@ -1294,7 +1400,8 @@ TEST(FDv2DataSystemTest, FDv1SourceSelfDirectiveDoesNotRebuildFDv1) { FDv2SourceResult::ErrorInfo::ErrorKind::kErrorResponse, /*status_code=*/418, "self-trigger", std::chrono::system_clock::now()}}}; - r.fdv1_fallback = data_interfaces::FDv1FallbackDirective{}; + r.fdv1_fallback = + data_interfaces::FDv1FallbackDirective{std::chrono::seconds{0}}; return r; }()}); auto fdv1_factory = diff --git a/libs/server-sdk/tests/source_manager_test.cpp b/libs/server-sdk/tests/source_manager_test.cpp index 77b207b46..68bfd5cc2 100644 --- a/libs/server-sdk/tests/source_manager_test.cpp +++ b/libs/server-sdk/tests/source_manager_test.cpp @@ -255,3 +255,44 @@ TEST(SourceManagerTest, SwitchToFDv1FallbackUnblocksPreviouslyBlockedFDv2) { EXPECT_EQ(1, fdv1_ptr->build_count); EXPECT_TRUE(mgr.IsCurrentSynchronizerFDv1Fallback()); } + +TEST(SourceManagerTest, SwitchBackToFDv2UnblocksFDv2AndBlocksFDv1) { + auto fdv2 = std::make_unique(); + auto* fdv2_ptr = fdv2.get(); + auto fdv1 = std::make_unique(); + std::vector> factories; + factories.push_back(std::move(fdv2)); + factories.push_back(std::move(fdv1)); + SourceManager mgr(std::move(factories)); + + // Switch to FDv1 first, then back to FDv2. + mgr.SwitchToFDv1Fallback(); + mgr.SwitchBackToFDv2(); + + EXPECT_EQ(1u, mgr.AvailableSynchronizerCount()); + auto sync = mgr.NextSynchronizer(); + ASSERT_NE(sync, nullptr); + EXPECT_EQ(1, fdv2_ptr->build_count); + EXPECT_FALSE(mgr.IsCurrentSynchronizerFDv1Fallback()); +} + +TEST(SourceManagerTest, SwitchBackToFDv2UnblocksTerminallyFailedFDv2Factory) { + auto fdv2 = std::make_unique(); + auto* fdv2_ptr = fdv2.get(); + std::vector> factories; + factories.push_back(std::move(fdv2)); + SourceManager mgr(std::move(factories)); + + // Simulate a terminal error blocking the FDv2 factory. + mgr.NextSynchronizer(); + mgr.BlockCurrentSynchronizer(); + EXPECT_EQ(0u, mgr.AvailableSynchronizerCount()); + + mgr.SwitchBackToFDv2(); + + // Previously-blocked FDv2 factory is now available again. + EXPECT_EQ(1u, mgr.AvailableSynchronizerCount()); + auto sync = mgr.NextSynchronizer(); + ASSERT_NE(sync, nullptr); + EXPECT_EQ(2, fdv2_ptr->build_count); +} From e1a9969cec8719654c751c53cd326c0259c1e80b Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Mon, 8 Jun 2026 16:55:43 -0700 Subject: [PATCH 17/30] test: adapt new TTL streaming tests to 545's endpoint signature --- libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp b/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp index 831fdc894..25bebefc9 100644 --- a/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp +++ b/libs/server-sdk/tests/fdv2_streaming_synchronizer_test.cpp @@ -834,7 +834,7 @@ TEST(FDv2StreamingSynchronizerTest, DirectiveWithTtlHeaderParsesValue) { FDv2StreamingSynchronizer synchronizer( runner.context().get_executor(), logger, - MakeEndpoints("http://localhost"), MakeHttpProperties(), std::nullopt, + "http://localhost", MakeHttpProperties(), std::nullopt, 1s); FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); @@ -863,7 +863,7 @@ TEST(FDv2StreamingSynchronizerTest, DirectiveWithoutTtlHeaderUsesDefault) { FDv2StreamingSynchronizer synchronizer( runner.context().get_executor(), logger, - MakeEndpoints("http://localhost"), MakeHttpProperties(), std::nullopt, + "http://localhost", MakeHttpProperties(), std::nullopt, 1s); FDv2StreamingSynchronizerTestPeer::MarkStarted(synchronizer); From 303b3a87041107936d05cead2035b4ee3ea5fff4 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Mon, 8 Jun 2026 17:07:28 -0700 Subject: [PATCH 18/30] chore: remove unused retry_after field from goodbye --- .../launchdarkly/serialization/json_fdv2_events.hpp | 2 -- libs/internal/src/serialization/json_fdv2_events.cpp | 1 - libs/internal/tests/fdv2_serialization_test.cpp | 10 ---------- 3 files changed, 13 deletions(-) diff --git a/libs/internal/include/launchdarkly/serialization/json_fdv2_events.hpp b/libs/internal/include/launchdarkly/serialization/json_fdv2_events.hpp index fcf62af4c..3088b859c 100644 --- a/libs/internal/include/launchdarkly/serialization/json_fdv2_events.hpp +++ b/libs/internal/include/launchdarkly/serialization/json_fdv2_events.hpp @@ -47,8 +47,6 @@ struct Goodbye { std::optional reason; // If set, indicates an FDv1 fallback directive with this TTL in seconds. std::optional protocol_fallback_ttl; - // If set, indicates a Retry-After equivalent in seconds. - std::optional retry_after; }; struct FDv2Error { diff --git a/libs/internal/src/serialization/json_fdv2_events.cpp b/libs/internal/src/serialization/json_fdv2_events.cpp index dc14d0205..34dfa99cf 100644 --- a/libs/internal/src/serialization/json_fdv2_events.cpp +++ b/libs/internal/src/serialization/json_fdv2_events.cpp @@ -134,7 +134,6 @@ tl::expected, JsonError> tag_invoke( PARSE_CONDITIONAL_FIELD(goodbye.reason, obj, "reason"); PARSE_CONDITIONAL_FIELD(goodbye.protocol_fallback_ttl, obj, "protocolFallbackTTL"); - PARSE_CONDITIONAL_FIELD(goodbye.retry_after, obj, "retryAfter"); return goodbye; } diff --git a/libs/internal/tests/fdv2_serialization_test.cpp b/libs/internal/tests/fdv2_serialization_test.cpp index fa194a103..1df425bc0 100644 --- a/libs/internal/tests/fdv2_serialization_test.cpp +++ b/libs/internal/tests/fdv2_serialization_test.cpp @@ -373,16 +373,6 @@ TEST(GoodbyeTests, DeserializesWithProtocolFallbackTtl) { ASSERT_EQ(60, *result.value()->protocol_fallback_ttl); } -TEST(GoodbyeTests, DeserializesWithRetryAfter) { - auto result = - boost::json::value_to, JsonError>>( - boost::json::parse(R"({"retryAfter":5})")); - ASSERT_TRUE(result); - ASSERT_TRUE(result.value()); - ASSERT_TRUE(result.value()->retry_after); - ASSERT_EQ(5, *result.value()->retry_after); -} - TEST(GoodbyeTests, WrongTypeReturnsSchemaFailure) { auto result = boost::json::value_to, JsonError>>( From b1157e18f6bb0388a32c50379a1fac5528242db6 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Tue, 9 Jun 2026 10:07:36 -0700 Subject: [PATCH 19/30] feat: translate FDv1 status changes to FDv2 results in FDv1AdapterSynchronizer --- .../fdv2/fdv1_adapter_synchronizer.cpp | 26 +++- .../fdv2/fdv1_adapter_synchronizer.hpp | 33 ++++- .../tests/fdv1_adapter_synchronizer_test.cpp | 119 +++++++++++++++--- 3 files changed, 155 insertions(+), 23 deletions(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.cpp index 2dfd953cb..0fd6094d3 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.cpp @@ -5,6 +5,7 @@ namespace launchdarkly::server_side::data_systems { using data_interfaces::FDv2SourceResult; +using DataSourceState = DataSourceStatus::DataSourceState; // ----- State ----- @@ -132,9 +133,32 @@ std::string const& FDv1AdapterSynchronizer::ConvertingDestination::Identity() // ----- FDv1AdapterSynchronizer ----- FDv1AdapterSynchronizer::FDv1AdapterSynchronizer( - std::unique_ptr fdv1_source) + std::unique_ptr fdv1_source, + data_components::DataSourceStatusManager* status_manager) : state_(std::make_shared()), destination_(std::make_unique(state_)), + status_manager_(status_manager), + status_subscription_(status_manager_->OnDataSourceStatusChange( + [state = state_](DataSourceStatus status) { + auto error = status.LastError(); + if (!error) { + return; + } + switch (status.State()) { + case DataSourceState::kInterrupted: + state->Notify(FDv2SourceResult{ + FDv2SourceResult::Interrupted{*error}}); + break; + case DataSourceState::kOff: + state->Notify(FDv2SourceResult{ + FDv2SourceResult::TerminalError{*error}}); + break; + case DataSourceState::kInitializing: + case DataSourceState::kValid: + // No FDv2 result for these states. + break; + } + })), fdv1_source_(std::move(fdv1_source)) {} FDv1AdapterSynchronizer::~FDv1AdapterSynchronizer() { diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.hpp b/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.hpp index 7d2d10a3e..e52b1b321 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.hpp @@ -1,10 +1,12 @@ #pragma once +#include "../../data_components/status_notifications/data_source_status_manager.hpp" #include "../../data_interfaces/destination/idestination.hpp" #include "../../data_interfaces/source/idata_synchronizer.hpp" #include "../../data_interfaces/source/ifdv2_synchronizer.hpp" #include +#include #include #include @@ -23,15 +25,26 @@ namespace launchdarkly::server_side::data_systems { * and fdv1_fallback = false (the directive does not re-fire from FDv1 data). * * Threading: Next() and Close() may be called from any thread; only one - * Next() may be outstanding at a time. The adapter blocks in its destructor - * waiting for the FDv1 source's ShutdownAsync completion, so no callbacks - * are in flight when the wrapped source is destroyed. + * Next() may be outstanding at a time. Member declaration order ensures + * the wrapped FDv1 source destructs before destination_ and state_, so any + * in-flight FDv1 callbacks land on live objects during teardown. This + * relies on the wrapped IDataSynchronizer blocking on its in-flight work + * in its destructor. */ class FDv1AdapterSynchronizer final : public data_interfaces::IFDv2Synchronizer { public: - explicit FDv1AdapterSynchronizer( - std::unique_ptr fdv1_source); + /** + * @param fdv1_source The wrapped source. Must have been constructed + * with status_manager as its status sink so that + * state changes flow back into this adapter. + * @param status_manager Non-owning. The caller retains ownership and + * must keep it alive for the lifetime of the + * wrapped source and this adapter. + */ + FDv1AdapterSynchronizer( + std::unique_ptr fdv1_source, + data_components::DataSourceStatusManager* status_manager); ~FDv1AdapterSynchronizer() override; @@ -95,9 +108,17 @@ class FDv1AdapterSynchronizer final std::weak_ptr state_; }; - // const after construction. + // shared_ptr so async callbacks that may fire after this is destroyed + // can hold their own reference. std::shared_ptr const state_; std::unique_ptr const destination_; + + // Non-owning. The caller must keep this alive for the lifetime of + // the wrapped source, which holds a reference to it for status + // reporting. + data_components::DataSourceStatusManager* const status_manager_; + std::unique_ptr const status_subscription_; + std::unique_ptr const fdv1_source_; // Thread-safe primitive. diff --git a/libs/server-sdk/tests/fdv1_adapter_synchronizer_test.cpp b/libs/server-sdk/tests/fdv1_adapter_synchronizer_test.cpp index 77058f6bc..da1408b94 100644 --- a/libs/server-sdk/tests/fdv1_adapter_synchronizer_test.cpp +++ b/libs/server-sdk/tests/fdv1_adapter_synchronizer_test.cpp @@ -8,6 +8,8 @@ #include using namespace launchdarkly; +using namespace launchdarkly::server_side; +using namespace launchdarkly::server_side::data_components; using namespace launchdarkly::server_side::data_interfaces; using namespace launchdarkly::server_side::data_systems; using namespace std::chrono_literals; @@ -18,6 +20,8 @@ namespace { // the IDestination it was given so the test can drive Init/Upsert. class MockFDv1Source final : public IDataSynchronizer { public: + explicit MockFDv1Source(DataSourceStatusManager& /*status_manager*/) {} + void StartAsync(IDestination* destination, data_model::SDKDataSet const* bootstrap) override { ++start_count; @@ -46,9 +50,10 @@ class MockFDv1Source final : public IDataSynchronizer { } // namespace TEST(FDv1AdapterSynchronizerTest, FirstNextStartsFDv1Source) { - auto source = std::make_unique(); + DataSourceStatusManager status_manager; + auto source = std::make_unique(status_manager); auto* source_ptr = source.get(); - FDv1AdapterSynchronizer adapter(std::move(source)); + FDv1AdapterSynchronizer adapter(std::move(source), &status_manager); auto future = adapter.Next(data_model::Selector{}); @@ -58,9 +63,10 @@ TEST(FDv1AdapterSynchronizerTest, FirstNextStartsFDv1Source) { } TEST(FDv1AdapterSynchronizerTest, SecondNextDoesNotRestartSource) { - auto source = std::make_unique(); + DataSourceStatusManager status_manager; + auto source = std::make_unique(status_manager); auto* source_ptr = source.get(); - FDv1AdapterSynchronizer adapter(std::move(source)); + FDv1AdapterSynchronizer adapter(std::move(source), &status_manager); auto first = adapter.Next(data_model::Selector{}); source_ptr->destination_->Init(data_model::SDKDataSet{}); @@ -72,9 +78,10 @@ TEST(FDv1AdapterSynchronizerTest, SecondNextDoesNotRestartSource) { } TEST(FDv1AdapterSynchronizerTest, FDv1InitProducesFullChangeSet) { - auto source = std::make_unique(); + DataSourceStatusManager status_manager; + auto source = std::make_unique(status_manager); auto* source_ptr = source.get(); - FDv1AdapterSynchronizer adapter(std::move(source)); + FDv1AdapterSynchronizer adapter(std::move(source), &status_manager); auto future = adapter.Next(data_model::Selector{}); @@ -97,9 +104,10 @@ TEST(FDv1AdapterSynchronizerTest, FDv1InitProducesFullChangeSet) { } TEST(FDv1AdapterSynchronizerTest, FDv1UpsertProducesPartialChangeSet) { - auto source = std::make_unique(); + DataSourceStatusManager status_manager; + auto source = std::make_unique(status_manager); auto* source_ptr = source.get(); - FDv1AdapterSynchronizer adapter(std::move(source)); + FDv1AdapterSynchronizer adapter(std::move(source), &status_manager); // Flag upsert. auto flag_future = adapter.Next(data_model::Selector{}); @@ -138,8 +146,9 @@ TEST(FDv1AdapterSynchronizerTest, FDv1UpsertProducesPartialChangeSet) { } TEST(FDv1AdapterSynchronizerTest, ClosePendingNextReturnsShutdown) { - auto source = std::make_unique(); - FDv1AdapterSynchronizer adapter(std::move(source)); + DataSourceStatusManager status_manager; + auto source = std::make_unique(status_manager); + FDv1AdapterSynchronizer adapter(std::move(source), &status_manager); auto future = adapter.Next(data_model::Selector{}); EXPECT_FALSE(future.IsFinished()); @@ -153,9 +162,10 @@ TEST(FDv1AdapterSynchronizerTest, ClosePendingNextReturnsShutdown) { } TEST(FDv1AdapterSynchronizerTest, CloseShutsDownStartedFDv1Source) { - auto source = std::make_unique(); + DataSourceStatusManager status_manager; + auto source = std::make_unique(status_manager); auto* source_ptr = source.get(); - FDv1AdapterSynchronizer adapter(std::move(source)); + FDv1AdapterSynchronizer adapter(std::move(source), &status_manager); adapter.Next(data_model::Selector{}); adapter.Close(); @@ -164,9 +174,10 @@ TEST(FDv1AdapterSynchronizerTest, CloseShutsDownStartedFDv1Source) { } TEST(FDv1AdapterSynchronizerTest, CloseWithoutStartDoesNotShutDownFDv1Source) { - auto source = std::make_unique(); + DataSourceStatusManager status_manager; + auto source = std::make_unique(status_manager); auto* source_ptr = source.get(); - FDv1AdapterSynchronizer adapter(std::move(source)); + FDv1AdapterSynchronizer adapter(std::move(source), &status_manager); // No Next() call — FDv1 source was never started. adapter.Close(); @@ -175,9 +186,85 @@ TEST(FDv1AdapterSynchronizerTest, CloseWithoutStartDoesNotShutDownFDv1Source) { EXPECT_EQ(0, source_ptr->shutdown_count); } +TEST(FDv1AdapterSynchronizerTest, QueuedResultsDrainInFifoOrder) { + DataSourceStatusManager status_manager; + auto source = std::make_unique(status_manager); + auto* source_ptr = source.get(); + FDv1AdapterSynchronizer adapter(std::move(source), &status_manager); + + // Start the source by satisfying one Next() with an Init. + auto first = adapter.Next(data_model::Selector{}); + source_ptr->destination_->Init(data_model::SDKDataSet{}); + first.WaitForResult(1s); + + // Two upserts queue with no Next() in flight. + data_model::Flag flag_a; + flag_a.key = "a"; + data_model::Flag flag_b; + flag_b.key = "b"; + source_ptr->destination_->Upsert("a", data_model::FlagDescriptor(flag_a)); + source_ptr->destination_->Upsert("b", data_model::FlagDescriptor(flag_b)); + + // Drain in FIFO order. + auto r1 = adapter.Next(data_model::Selector{}).WaitForResult(1s); + auto r2 = adapter.Next(data_model::Selector{}).WaitForResult(1s); + ASSERT_TRUE(r1.has_value()); + ASSERT_TRUE(r2.has_value()); + auto* cs1 = std::get_if(&r1->value); + auto* cs2 = std::get_if(&r2->value); + ASSERT_NE(cs1, nullptr); + ASSERT_NE(cs2, nullptr); + ASSERT_EQ(1u, cs1->change_set.data.size()); + ASSERT_EQ(1u, cs2->change_set.data.size()); + EXPECT_EQ("a", cs1->change_set.data[0].key); + EXPECT_EQ("b", cs2->change_set.data[0].key); +} + +TEST(FDv1AdapterSynchronizerTest, InterruptedStatusProducesInterruptedResult) { + DataSourceStatusManager status_manager; + auto source = std::make_unique(status_manager); + FDv1AdapterSynchronizer adapter(std::move(source), &status_manager); + + // kInterrupted from kInitializing stays kInitializing; drive past first. + status_manager.SetState(DataSourceStatus::DataSourceState::kValid); + + auto future = adapter.Next(data_model::Selector{}); + status_manager.SetState( + DataSourceStatus::DataSourceState::kInterrupted, + DataSourceStatus::ErrorInfo::ErrorKind::kNetworkError, "boom"); + + auto result = future.WaitForResult(1s); + ASSERT_TRUE(result.has_value()); + auto* interrupted = + std::get_if(&result->value); + ASSERT_NE(interrupted, nullptr); + EXPECT_EQ(DataSourceStatus::ErrorInfo::ErrorKind::kNetworkError, + interrupted->error.Kind()); +} + +TEST(FDv1AdapterSynchronizerTest, OffStatusProducesTerminalErrorResult) { + DataSourceStatusManager status_manager; + auto source = std::make_unique(status_manager); + FDv1AdapterSynchronizer adapter(std::move(source), &status_manager); + + auto future = adapter.Next(data_model::Selector{}); + status_manager.SetState( + DataSourceStatus::DataSourceState::kOff, + DataSourceStatus::ErrorInfo::ErrorKind::kErrorResponse, "401"); + + auto result = future.WaitForResult(1s); + ASSERT_TRUE(result.has_value()); + auto* terminal = + std::get_if(&result->value); + ASSERT_NE(terminal, nullptr); + EXPECT_EQ(DataSourceStatus::ErrorInfo::ErrorKind::kErrorResponse, + terminal->error.Kind()); +} + TEST(FDv1AdapterSynchronizerTest, NextAfterCloseReturnsShutdown) { - auto source = std::make_unique(); - FDv1AdapterSynchronizer adapter(std::move(source)); + DataSourceStatusManager status_manager; + auto source = std::make_unique(status_manager); + FDv1AdapterSynchronizer adapter(std::move(source), &status_manager); adapter.Close(); auto future = adapter.Next(data_model::Selector{}); From b2eae57495c5de8916dd7c81c29f2857f2793ec5 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Tue, 9 Jun 2026 10:39:42 -0700 Subject: [PATCH 20/30] fix: pass status manager to FDv1AdapterSynchronizer --- .../src/data_systems/fdv2/synchronizer_factories.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp index 30036b56c..ad6615d45 100644 --- a/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp @@ -71,7 +71,8 @@ FDv1StreamingAdapterFactory::Build() { auto fdv1_source = std::make_unique( executor_, logger_, *status_manager_, endpoints_, streaming_, http_properties_); - return std::make_unique(std::move(fdv1_source)); + return std::make_unique(std::move(fdv1_source), + status_manager_); } FDv1PollingAdapterFactory::FDv1PollingAdapterFactory( @@ -93,7 +94,8 @@ FDv1PollingAdapterFactory::Build() { auto fdv1_source = std::make_unique( executor_, logger_, *status_manager_, endpoints_, polling_, http_properties_); - return std::make_unique(std::move(fdv1_source)); + return std::make_unique(std::move(fdv1_source), + status_manager_); } } // namespace launchdarkly::server_side::data_systems From 9a93aabfe8bb1035c2a1edc3a08b9034cbdf57fc Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 11 Jun 2026 09:41:07 -0700 Subject: [PATCH 21/30] docs: explain got_basis reuse in FDv1 fallback branch --- libs/server-sdk/src/data_systems/fdv2/fdv2_data_system.cpp | 3 +++ 1 file changed, 3 insertions(+) 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 16bc7d8a0..222c0d147 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 @@ -198,6 +198,9 @@ void FDv2DataSystem::OnInitializerResult( if (source_manager_.AvailableSynchronizerCount() > 0) { LD_LOG(logger_, LogLevel::kInfo) << Identity() << ": FDv1 fallback engaged"; + // No basis yet; reuse the flag to fall through to + // StartSynchronizers so the FDv1 fallback synchronizer can + // produce it. got_basis = true; } else { LD_LOG(logger_, LogLevel::kWarn) From 67126a99a9e8e4747d3062afd4fc8c61f7e471af Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 11 Jun 2026 10:06:40 -0700 Subject: [PATCH 22/30] fix: reset FDv1 fallback retry source between schedules --- libs/server-sdk/src/data_systems/fdv2/fdv2_data_system.cpp | 5 +++++ 1 file changed, 5 insertions(+) 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 222c0d147..fcb980c3c 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 @@ -433,6 +433,11 @@ void FDv2DataSystem::ScheduleFDv2RetryLocked(std::chrono::seconds ttl) { if (ttl == std::chrono::seconds::zero()) { return; } + // Cancel any in-flight retry and start fresh; CancellationSource is + // one-shot, so reusing the same source for successive schedules would + // leak prior timers. + fdv1_fallback_retry_cancel_.Cancel(); + fdv1_fallback_retry_cancel_ = async::CancellationSource{}; LD_LOG(logger_, LogLevel::kInfo) << Identity() << ": FDv2 retry scheduled in " << ttl.count() << "s"; async::Delay(ioc_, ttl, fdv1_fallback_retry_cancel_.GetToken()) From 27f5c1ed9b95d531989b79d6bd3b2ac555bd1b92 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 11 Jun 2026 13:15:32 -0700 Subject: [PATCH 23/30] fix: serialize StartAsync/ShutdownAsync on wrapped FDv1 source --- .../fdv2/fdv1_adapter_synchronizer.cpp | 36 +++++++++---------- .../fdv2/fdv1_adapter_synchronizer.hpp | 35 +++++++++--------- 2 files changed, 35 insertions(+), 36 deletions(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.cpp index 0fd6094d3..58d0362c4 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.cpp @@ -9,20 +9,9 @@ using DataSourceState = DataSourceStatus::DataSourceState; // ----- State ----- -bool FDv1AdapterSynchronizer::State::TryStart() { - std::lock_guard lock(mutex_); - if (started_ || closed_) { - return false; - } - started_ = true; - return true; -} - -bool FDv1AdapterSynchronizer::State::MarkClosed() { - std::lock_guard lock(mutex_); - closed_ = true; - return started_; -} +FDv1AdapterSynchronizer::State::State( + async::Future closed_future) + : closed_future_(std::move(closed_future)) {} async::Future FDv1AdapterSynchronizer::State::GetNext() { std::lock_guard lock(mutex_); @@ -52,7 +41,7 @@ void FDv1AdapterSynchronizer::State::Notify(FDv2SourceResult result) { std::optional> promise; { std::lock_guard lock(mutex_); - if (closed_) { + if (closed_future_.IsFinished()) { return; } if (pending_promise_) { @@ -135,7 +124,7 @@ std::string const& FDv1AdapterSynchronizer::ConvertingDestination::Identity() FDv1AdapterSynchronizer::FDv1AdapterSynchronizer( std::unique_ptr fdv1_source, data_components::DataSourceStatusManager* status_manager) - : state_(std::make_shared()), + : state_(std::make_shared(close_promise_.GetFuture())), destination_(std::make_unique(state_)), status_manager_(status_manager), status_subscription_(status_manager_->OnDataSourceStatusChange( @@ -172,9 +161,13 @@ async::Future FDv1AdapterSynchronizer::Next( return async::MakeFuture( FDv2SourceResult{FDv2SourceResult::Shutdown{}}); } - if (state_->TryStart()) { - fdv1_source_->StartAsync(destination_.get(), - /*bootstrap_data=*/nullptr); + { + std::lock_guard lock(lifecycle_mutex_); + if (!started_) { + started_ = true; + fdv1_source_->StartAsync(destination_.get(), + /*bootstrap_data=*/nullptr); + } } auto result_future = state_->GetNext(); if (result_future.IsFinished()) { @@ -198,7 +191,10 @@ void FDv1AdapterSynchronizer::Close() { if (!close_promise_.Resolve(std::monostate{})) { return; } - if (state_->MarkClosed()) { + std::lock_guard lock(lifecycle_mutex_); + bool const was_started = started_; + started_ = true; + if (was_started) { fdv1_source_->ShutdownAsync([] {}); } } diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.hpp b/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.hpp index e52b1b321..8c0085cdf 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.hpp @@ -55,21 +55,13 @@ class FDv1AdapterSynchronizer final private: /** - * Holds the lifecycle, result queue, and pending Next() promise; shared - * with the FDv1 source's IDestination via the inner ConvertingDestination. + * Holds the result queue and pending Next() promise; shared with the + * FDv1 source's IDestination via the inner ConvertingDestination. * All methods are thread-safe. */ class State { public: - // Returns true if this call transitioned Initial → Started; false if - // already started or already closed. Used to gate the one-time - // StartAsync call on the wrapped FDv1 source. - bool TryStart(); - - // Marks the state closed and returns whether the source was started - // before the transition (so the caller knows whether ShutdownAsync - // needs to be called). - bool MarkClosed(); + explicit State(async::Future closed_future); async::Future GetNext(); @@ -81,10 +73,12 @@ class FDv1AdapterSynchronizer final void Notify(data_interfaces::FDv2SourceResult result); private: - // Protected by mutex_. + // Finished once the owning FDv1AdapterSynchronizer's close_promise_ + // is resolved. Read in Notify to drop late results. + async::Future const closed_future_; + mutable std::mutex mutex_; - bool started_ = false; - bool closed_ = false; + // Protected by mutex_. std::optional> pending_promise_; std::deque result_queue_; @@ -108,6 +102,10 @@ class FDv1AdapterSynchronizer final std::weak_ptr state_; }; + // Thread-safe primitive. Declared before state_ so state_'s constructor + // can take a future from it. + async::Promise close_promise_; + // shared_ptr so async callbacks that may fire after this is destroyed // can hold their own reference. std::shared_ptr const state_; @@ -121,8 +119,13 @@ class FDv1AdapterSynchronizer final std::unique_ptr const fdv1_source_; - // Thread-safe primitive. - async::Promise close_promise_; + // Serializes StartAsync and ShutdownAsync on fdv1_source_ across + // concurrent Next() and Close() calls. + std::mutex lifecycle_mutex_; + // Protected by lifecycle_mutex_. Set when Next() calls StartAsync, or + // when Close() runs without a prior start (to gate any later Next() + // from calling StartAsync after Close). + bool started_ = false; }; } // namespace launchdarkly::server_side::data_systems From 6c428c526c7c473ac4e8fa50c9706bfaa64a1fd9 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 11 Jun 2026 15:06:18 -0700 Subject: [PATCH 24/30] fix: give FDv1 adapter a private status manager --- .../fdv2/fdv1_adapter_synchronizer.cpp | 9 +- .../fdv2/fdv1_adapter_synchronizer.hpp | 25 ++-- .../tests/fdv1_adapter_synchronizer_test.cpp | 111 +++++++++--------- 3 files changed, 70 insertions(+), 75 deletions(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.cpp index 58d0362c4..9896ca539 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.cpp @@ -121,12 +121,11 @@ std::string const& FDv1AdapterSynchronizer::ConvertingDestination::Identity() // ----- FDv1AdapterSynchronizer ----- -FDv1AdapterSynchronizer::FDv1AdapterSynchronizer( - std::unique_ptr fdv1_source, - data_components::DataSourceStatusManager* status_manager) +FDv1AdapterSynchronizer::FDv1AdapterSynchronizer(SourceBuilder source_builder) : state_(std::make_shared(close_promise_.GetFuture())), destination_(std::make_unique(state_)), - status_manager_(status_manager), + status_manager_( + std::make_unique()), status_subscription_(status_manager_->OnDataSourceStatusChange( [state = state_](DataSourceStatus status) { auto error = status.LastError(); @@ -148,7 +147,7 @@ FDv1AdapterSynchronizer::FDv1AdapterSynchronizer( break; } })), - fdv1_source_(std::move(fdv1_source)) {} + fdv1_source_(source_builder(*status_manager_)) {} FDv1AdapterSynchronizer::~FDv1AdapterSynchronizer() { Close(); diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.hpp b/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.hpp index 8c0085cdf..b6e95a3ca 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.hpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -34,17 +35,17 @@ namespace launchdarkly::server_side::data_systems { class FDv1AdapterSynchronizer final : public data_interfaces::IFDv2Synchronizer { public: + using SourceBuilder = + std::function( + data_components::DataSourceStatusManager&)>; + /** - * @param fdv1_source The wrapped source. Must have been constructed - * with status_manager as its status sink so that - * state changes flow back into this adapter. - * @param status_manager Non-owning. The caller retains ownership and - * must keep it alive for the lifetime of the - * wrapped source and this adapter. + * @param source_builder Called once during construction with the + * adapter's status manager. Returns the wrapped + * FDv1 source, which must be constructed against + * the provided manager as its status sink. */ - FDv1AdapterSynchronizer( - std::unique_ptr fdv1_source, - data_components::DataSourceStatusManager* status_manager); + explicit FDv1AdapterSynchronizer(SourceBuilder source_builder); ~FDv1AdapterSynchronizer() override; @@ -111,10 +112,8 @@ class FDv1AdapterSynchronizer final std::shared_ptr const state_; std::unique_ptr const destination_; - // Non-owning. The caller must keep this alive for the lifetime of - // the wrapped source, which holds a reference to it for status - // reporting. - data_components::DataSourceStatusManager* const status_manager_; + std::unique_ptr const + status_manager_; std::unique_ptr const status_subscription_; std::unique_ptr const fdv1_source_; diff --git a/libs/server-sdk/tests/fdv1_adapter_synchronizer_test.cpp b/libs/server-sdk/tests/fdv1_adapter_synchronizer_test.cpp index da1408b94..39312d98e 100644 --- a/libs/server-sdk/tests/fdv1_adapter_synchronizer_test.cpp +++ b/libs/server-sdk/tests/fdv1_adapter_synchronizer_test.cpp @@ -47,41 +47,53 @@ class MockFDv1Source final : public IDataSynchronizer { bool bootstrap_was_null = false; }; +// Returns a SourceBuilder closure that constructs a MockFDv1Source. The +// resulting source and the adapter's internal status manager are exposed +// through the out parameters; pass nullptr for either to skip. +FDv1AdapterSynchronizer::SourceBuilder MakeMockBuilder( + MockFDv1Source** out_source = nullptr, + DataSourceStatusManager** out_sm = nullptr) { + return [out_source, out_sm](DataSourceStatusManager& sm) { + if (out_sm) { + *out_sm = &sm; + } + auto source = std::make_unique(sm); + if (out_source) { + *out_source = source.get(); + } + return source; + }; +} + } // namespace TEST(FDv1AdapterSynchronizerTest, FirstNextStartsFDv1Source) { - DataSourceStatusManager status_manager; - auto source = std::make_unique(status_manager); - auto* source_ptr = source.get(); - FDv1AdapterSynchronizer adapter(std::move(source), &status_manager); + MockFDv1Source* source = nullptr; + FDv1AdapterSynchronizer adapter(MakeMockBuilder(&source)); auto future = adapter.Next(data_model::Selector{}); - EXPECT_EQ(1, source_ptr->start_count); - EXPECT_TRUE(source_ptr->bootstrap_was_null); + EXPECT_EQ(1, source->start_count); + EXPECT_TRUE(source->bootstrap_was_null); EXPECT_FALSE(future.IsFinished()); } TEST(FDv1AdapterSynchronizerTest, SecondNextDoesNotRestartSource) { - DataSourceStatusManager status_manager; - auto source = std::make_unique(status_manager); - auto* source_ptr = source.get(); - FDv1AdapterSynchronizer adapter(std::move(source), &status_manager); + MockFDv1Source* source = nullptr; + FDv1AdapterSynchronizer adapter(MakeMockBuilder(&source)); auto first = adapter.Next(data_model::Selector{}); - source_ptr->destination_->Init(data_model::SDKDataSet{}); + source->destination_->Init(data_model::SDKDataSet{}); auto result = first.WaitForResult(1s); ASSERT_TRUE(result.has_value()); adapter.Next(data_model::Selector{}); - EXPECT_EQ(1, source_ptr->start_count); + EXPECT_EQ(1, source->start_count); } TEST(FDv1AdapterSynchronizerTest, FDv1InitProducesFullChangeSet) { - DataSourceStatusManager status_manager; - auto source = std::make_unique(status_manager); - auto* source_ptr = source.get(); - FDv1AdapterSynchronizer adapter(std::move(source), &status_manager); + MockFDv1Source* source = nullptr; + FDv1AdapterSynchronizer adapter(MakeMockBuilder(&source)); auto future = adapter.Next(data_model::Selector{}); @@ -90,7 +102,7 @@ TEST(FDv1AdapterSynchronizerTest, FDv1InitProducesFullChangeSet) { flag.key = "flagA"; flag.version = 1; data_set.flags.emplace("flagA", data_model::FlagDescriptor(flag)); - source_ptr->destination_->Init(std::move(data_set)); + source->destination_->Init(std::move(data_set)); auto result = future.WaitForResult(1s); ASSERT_TRUE(result.has_value()); @@ -104,17 +116,15 @@ TEST(FDv1AdapterSynchronizerTest, FDv1InitProducesFullChangeSet) { } TEST(FDv1AdapterSynchronizerTest, FDv1UpsertProducesPartialChangeSet) { - DataSourceStatusManager status_manager; - auto source = std::make_unique(status_manager); - auto* source_ptr = source.get(); - FDv1AdapterSynchronizer adapter(std::move(source), &status_manager); + MockFDv1Source* source = nullptr; + FDv1AdapterSynchronizer adapter(MakeMockBuilder(&source)); // Flag upsert. auto flag_future = adapter.Next(data_model::Selector{}); data_model::Flag flag; flag.key = "flagA"; flag.version = 2; - source_ptr->destination_->Upsert("flagA", data_model::FlagDescriptor(flag)); + source->destination_->Upsert("flagA", data_model::FlagDescriptor(flag)); auto flag_result = flag_future.WaitForResult(1s); ASSERT_TRUE(flag_result.has_value()); @@ -131,8 +141,7 @@ TEST(FDv1AdapterSynchronizerTest, FDv1UpsertProducesPartialChangeSet) { data_model::Segment seg; seg.key = "segA"; seg.version = 3; - source_ptr->destination_->Upsert("segA", - data_model::SegmentDescriptor(seg)); + source->destination_->Upsert("segA", data_model::SegmentDescriptor(seg)); auto seg_result = seg_future.WaitForResult(1s); ASSERT_TRUE(seg_result.has_value()); @@ -146,9 +155,7 @@ TEST(FDv1AdapterSynchronizerTest, FDv1UpsertProducesPartialChangeSet) { } TEST(FDv1AdapterSynchronizerTest, ClosePendingNextReturnsShutdown) { - DataSourceStatusManager status_manager; - auto source = std::make_unique(status_manager); - FDv1AdapterSynchronizer adapter(std::move(source), &status_manager); + FDv1AdapterSynchronizer adapter(MakeMockBuilder()); auto future = adapter.Next(data_model::Selector{}); EXPECT_FALSE(future.IsFinished()); @@ -162,39 +169,33 @@ TEST(FDv1AdapterSynchronizerTest, ClosePendingNextReturnsShutdown) { } TEST(FDv1AdapterSynchronizerTest, CloseShutsDownStartedFDv1Source) { - DataSourceStatusManager status_manager; - auto source = std::make_unique(status_manager); - auto* source_ptr = source.get(); - FDv1AdapterSynchronizer adapter(std::move(source), &status_manager); + MockFDv1Source* source = nullptr; + FDv1AdapterSynchronizer adapter(MakeMockBuilder(&source)); adapter.Next(data_model::Selector{}); adapter.Close(); - EXPECT_EQ(1, source_ptr->shutdown_count); + EXPECT_EQ(1, source->shutdown_count); } TEST(FDv1AdapterSynchronizerTest, CloseWithoutStartDoesNotShutDownFDv1Source) { - DataSourceStatusManager status_manager; - auto source = std::make_unique(status_manager); - auto* source_ptr = source.get(); - FDv1AdapterSynchronizer adapter(std::move(source), &status_manager); + MockFDv1Source* source = nullptr; + FDv1AdapterSynchronizer adapter(MakeMockBuilder(&source)); // No Next() call — FDv1 source was never started. adapter.Close(); - EXPECT_EQ(0, source_ptr->start_count); - EXPECT_EQ(0, source_ptr->shutdown_count); + EXPECT_EQ(0, source->start_count); + EXPECT_EQ(0, source->shutdown_count); } TEST(FDv1AdapterSynchronizerTest, QueuedResultsDrainInFifoOrder) { - DataSourceStatusManager status_manager; - auto source = std::make_unique(status_manager); - auto* source_ptr = source.get(); - FDv1AdapterSynchronizer adapter(std::move(source), &status_manager); + MockFDv1Source* source = nullptr; + FDv1AdapterSynchronizer adapter(MakeMockBuilder(&source)); // Start the source by satisfying one Next() with an Init. auto first = adapter.Next(data_model::Selector{}); - source_ptr->destination_->Init(data_model::SDKDataSet{}); + source->destination_->Init(data_model::SDKDataSet{}); first.WaitForResult(1s); // Two upserts queue with no Next() in flight. @@ -202,8 +203,8 @@ TEST(FDv1AdapterSynchronizerTest, QueuedResultsDrainInFifoOrder) { flag_a.key = "a"; data_model::Flag flag_b; flag_b.key = "b"; - source_ptr->destination_->Upsert("a", data_model::FlagDescriptor(flag_a)); - source_ptr->destination_->Upsert("b", data_model::FlagDescriptor(flag_b)); + source->destination_->Upsert("a", data_model::FlagDescriptor(flag_a)); + source->destination_->Upsert("b", data_model::FlagDescriptor(flag_b)); // Drain in FIFO order. auto r1 = adapter.Next(data_model::Selector{}).WaitForResult(1s); @@ -221,15 +222,14 @@ TEST(FDv1AdapterSynchronizerTest, QueuedResultsDrainInFifoOrder) { } TEST(FDv1AdapterSynchronizerTest, InterruptedStatusProducesInterruptedResult) { - DataSourceStatusManager status_manager; - auto source = std::make_unique(status_manager); - FDv1AdapterSynchronizer adapter(std::move(source), &status_manager); + DataSourceStatusManager* source_manager = nullptr; + FDv1AdapterSynchronizer adapter(MakeMockBuilder(nullptr, &source_manager)); // kInterrupted from kInitializing stays kInitializing; drive past first. - status_manager.SetState(DataSourceStatus::DataSourceState::kValid); + source_manager->SetState(DataSourceStatus::DataSourceState::kValid); auto future = adapter.Next(data_model::Selector{}); - status_manager.SetState( + source_manager->SetState( DataSourceStatus::DataSourceState::kInterrupted, DataSourceStatus::ErrorInfo::ErrorKind::kNetworkError, "boom"); @@ -243,12 +243,11 @@ TEST(FDv1AdapterSynchronizerTest, InterruptedStatusProducesInterruptedResult) { } TEST(FDv1AdapterSynchronizerTest, OffStatusProducesTerminalErrorResult) { - DataSourceStatusManager status_manager; - auto source = std::make_unique(status_manager); - FDv1AdapterSynchronizer adapter(std::move(source), &status_manager); + DataSourceStatusManager* source_manager = nullptr; + FDv1AdapterSynchronizer adapter(MakeMockBuilder(nullptr, &source_manager)); auto future = adapter.Next(data_model::Selector{}); - status_manager.SetState( + source_manager->SetState( DataSourceStatus::DataSourceState::kOff, DataSourceStatus::ErrorInfo::ErrorKind::kErrorResponse, "401"); @@ -262,9 +261,7 @@ TEST(FDv1AdapterSynchronizerTest, OffStatusProducesTerminalErrorResult) { } TEST(FDv1AdapterSynchronizerTest, NextAfterCloseReturnsShutdown) { - DataSourceStatusManager status_manager; - auto source = std::make_unique(status_manager); - FDv1AdapterSynchronizer adapter(std::move(source), &status_manager); + FDv1AdapterSynchronizer adapter(MakeMockBuilder()); adapter.Close(); auto future = adapter.Next(data_model::Selector{}); From d75913931504058d4fed149ffa9071da43db096e Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 11 Jun 2026 15:09:40 -0700 Subject: [PATCH 25/30] fix: wire FDv1 adapter factories to new private-status-manager API --- libs/server-sdk/src/client_impl.cpp | 8 +++--- .../fdv2/synchronizer_factories.cpp | 26 +++++++++---------- .../fdv2/synchronizer_factories.hpp | 7 ----- 3 files changed, 16 insertions(+), 25 deletions(-) diff --git a/libs/server-sdk/src/client_impl.cpp b/libs/server-sdk/src/client_impl.cpp index 8bc91b61f..00d848c84 100644 --- a/libs/server-sdk/src/client_impl.cpp +++ b/libs/server-sdk/src/client_impl.cpp @@ -129,16 +129,16 @@ static std::unique_ptr MakeFDv2System( synchronizer_factories.push_back( std::make_unique< data_systems::FDv1StreamingAdapterFactory>( - executor, logger, &status_manager, endpoints, - streaming, http_properties)); + executor, logger, endpoints, streaming, + http_properties)); }, [&](config::built::FDv2Config::FDv1PollingConfig const& polling) { synchronizer_factories.push_back( std::make_unique< data_systems::FDv1PollingAdapterFactory>( - executor, logger, &status_manager, endpoints, - polling, http_properties)); + executor, logger, endpoints, polling, + http_properties)); }, }, *cfg.fdv1_fallback); diff --git a/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp index ad6615d45..9bbeca3ee 100644 --- a/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp @@ -55,47 +55,45 @@ FDv2PollingSynchronizerFactory::Build() { FDv1StreamingAdapterFactory::FDv1StreamingAdapterFactory( boost::asio::any_io_executor executor, Logger logger, - data_components::DataSourceStatusManager* status_manager, config::built::ServiceEndpoints endpoints, config::built::FDv2Config::FDv1StreamingConfig streaming, config::built::HttpProperties http_properties) : executor_(std::move(executor)), logger_(std::move(logger)), - status_manager_(status_manager), endpoints_(std::move(endpoints)), streaming_(std::move(streaming)), http_properties_(std::move(http_properties)) {} std::unique_ptr FDv1StreamingAdapterFactory::Build() { - auto fdv1_source = std::make_unique( - executor_, logger_, *status_manager_, endpoints_, streaming_, - http_properties_); - return std::make_unique(std::move(fdv1_source), - status_manager_); + return std::make_unique( + [this](data_components::DataSourceStatusManager& status_manager) { + return std::make_unique( + executor_, logger_, status_manager, endpoints_, streaming_, + http_properties_); + }); } FDv1PollingAdapterFactory::FDv1PollingAdapterFactory( boost::asio::any_io_executor executor, Logger logger, - data_components::DataSourceStatusManager* status_manager, config::built::ServiceEndpoints endpoints, config::built::FDv2Config::FDv1PollingConfig polling, config::built::HttpProperties http_properties) : executor_(std::move(executor)), logger_(std::move(logger)), - status_manager_(status_manager), endpoints_(std::move(endpoints)), polling_(std::move(polling)), http_properties_(std::move(http_properties)) {} std::unique_ptr FDv1PollingAdapterFactory::Build() { - auto fdv1_source = std::make_unique( - executor_, logger_, *status_manager_, endpoints_, polling_, - http_properties_); - return std::make_unique(std::move(fdv1_source), - status_manager_); + return std::make_unique( + [this](data_components::DataSourceStatusManager& status_manager) { + return std::make_unique( + executor_, logger_, status_manager, endpoints_, polling_, + http_properties_); + }); } } // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.hpp b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.hpp index c459733f9..3602df03f 100644 --- a/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.hpp @@ -1,6 +1,5 @@ #pragma once -#include "../../data_components/status_notifications/data_source_status_manager.hpp" #include "../../data_interfaces/source/ifdv2_synchronizer_factory.hpp" #include @@ -67,7 +66,6 @@ class FDv1StreamingAdapterFactory final FDv1StreamingAdapterFactory( boost::asio::any_io_executor executor, Logger logger, - data_components::DataSourceStatusManager* status_manager, config::built::ServiceEndpoints endpoints, config::built::FDv2Config::FDv1StreamingConfig streaming, config::built::HttpProperties http_properties); @@ -79,8 +77,6 @@ class FDv1StreamingAdapterFactory final private: boost::asio::any_io_executor const executor_; Logger const logger_; - // Non-owning. Provided by the orchestrator; must outlive this factory. - data_components::DataSourceStatusManager* const status_manager_; config::built::ServiceEndpoints const endpoints_; config::built::FDv2Config::FDv1StreamingConfig const streaming_; config::built::HttpProperties const http_properties_; @@ -96,7 +92,6 @@ class FDv1PollingAdapterFactory final FDv1PollingAdapterFactory( boost::asio::any_io_executor executor, Logger logger, - data_components::DataSourceStatusManager* status_manager, config::built::ServiceEndpoints endpoints, config::built::FDv2Config::FDv1PollingConfig polling, config::built::HttpProperties http_properties); @@ -108,8 +103,6 @@ class FDv1PollingAdapterFactory final private: boost::asio::any_io_executor const executor_; Logger const logger_; - // Non-owning. Provided by the orchestrator; must outlive this factory. - data_components::DataSourceStatusManager* const status_manager_; config::built::ServiceEndpoints const endpoints_; config::built::FDv2Config::FDv1PollingConfig const polling_; config::built::HttpProperties const http_properties_; From 2f689336ff82b16eb86f67fa1d19b9c3dfe1ea3f Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 11 Jun 2026 22:27:20 -0700 Subject: [PATCH 26/30] fix: hold FDv1 source via shared_ptr and forward init-time errors --- .../fdv2/fdv1_adapter_synchronizer.cpp | 6 ++++-- .../fdv2/fdv1_adapter_synchronizer.hpp | 4 ++-- .../tests/fdv1_adapter_synchronizer_test.cpp | 21 ++++++++++++++++++- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.cpp index 9896ca539..67bb98766 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.cpp @@ -134,16 +134,18 @@ FDv1AdapterSynchronizer::FDv1AdapterSynchronizer(SourceBuilder source_builder) } switch (status.State()) { case DataSourceState::kInterrupted: + case DataSourceState::kInitializing: + // Recoverable error. state->Notify(FDv2SourceResult{ FDv2SourceResult::Interrupted{*error}}); break; case DataSourceState::kOff: + // Terminal error. state->Notify(FDv2SourceResult{ FDv2SourceResult::TerminalError{*error}}); break; - case DataSourceState::kInitializing: case DataSourceState::kValid: - // No FDv2 result for these states. + // Recovery; no FDv2 result. break; } })), diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.hpp b/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.hpp index b6e95a3ca..a382ff90a 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv1_adapter_synchronizer.hpp @@ -36,7 +36,7 @@ class FDv1AdapterSynchronizer final : public data_interfaces::IFDv2Synchronizer { public: using SourceBuilder = - std::function( + std::function( data_components::DataSourceStatusManager&)>; /** @@ -116,7 +116,7 @@ class FDv1AdapterSynchronizer final status_manager_; std::unique_ptr const status_subscription_; - std::unique_ptr const fdv1_source_; + std::shared_ptr const fdv1_source_; // Serializes StartAsync and ShutdownAsync on fdv1_source_ across // concurrent Next() and Close() calls. diff --git a/libs/server-sdk/tests/fdv1_adapter_synchronizer_test.cpp b/libs/server-sdk/tests/fdv1_adapter_synchronizer_test.cpp index 39312d98e..26de674b6 100644 --- a/libs/server-sdk/tests/fdv1_adapter_synchronizer_test.cpp +++ b/libs/server-sdk/tests/fdv1_adapter_synchronizer_test.cpp @@ -57,7 +57,7 @@ FDv1AdapterSynchronizer::SourceBuilder MakeMockBuilder( if (out_sm) { *out_sm = &sm; } - auto source = std::make_unique(sm); + auto source = std::make_shared(sm); if (out_source) { *out_source = source.get(); } @@ -242,6 +242,25 @@ TEST(FDv1AdapterSynchronizerTest, InterruptedStatusProducesInterruptedResult) { interrupted->error.Kind()); } +TEST(FDv1AdapterSynchronizerTest, + InitializingWithErrorProducesInterruptedResult) { + DataSourceStatusManager* source_manager = nullptr; + FDv1AdapterSynchronizer adapter(MakeMockBuilder(nullptr, &source_manager)); + + auto future = adapter.Next(data_model::Selector{}); + source_manager->SetState( + DataSourceStatus::DataSourceState::kInterrupted, + DataSourceStatus::ErrorInfo::ErrorKind::kNetworkError, "boom"); + + auto result = future.WaitForResult(1s); + ASSERT_TRUE(result.has_value()); + auto* interrupted = + std::get_if(&result->value); + ASSERT_NE(interrupted, nullptr); + EXPECT_EQ(DataSourceStatus::ErrorInfo::ErrorKind::kNetworkError, + interrupted->error.Kind()); +} + TEST(FDv1AdapterSynchronizerTest, OffStatusProducesTerminalErrorResult) { DataSourceStatusManager* source_manager = nullptr; FDv1AdapterSynchronizer adapter(MakeMockBuilder(nullptr, &source_manager)); From 15243016b392150f44a0936c64e14b5cd52e526b Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 11 Jun 2026 22:33:38 -0700 Subject: [PATCH 27/30] fix: construct FDv1 sources via make_shared for enable_shared_from_this --- .../src/data_systems/fdv2/synchronizer_factories.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp index 9bbeca3ee..447e636ae 100644 --- a/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/synchronizer_factories.cpp @@ -68,7 +68,7 @@ std::unique_ptr FDv1StreamingAdapterFactory::Build() { return std::make_unique( [this](data_components::DataSourceStatusManager& status_manager) { - return std::make_unique( + return std::make_shared( executor_, logger_, status_manager, endpoints_, streaming_, http_properties_); }); @@ -90,7 +90,7 @@ std::unique_ptr FDv1PollingAdapterFactory::Build() { return std::make_unique( [this](data_components::DataSourceStatusManager& status_manager) { - return std::make_unique( + return std::make_shared( executor_, logger_, status_manager, endpoints_, polling_, http_properties_); }); From 83a5e4dc43c4543a419ac1d3f560dd920c6ba8a2 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Fri, 12 Jun 2026 14:32:35 -0700 Subject: [PATCH 28/30] fix: handle FDv2 intentCode none as listening, reject unknown codes --- libs/internal/src/fdv2_protocol_handler.cpp | 10 ++++-- .../tests/fdv2_protocol_handler_test.cpp | 31 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/libs/internal/src/fdv2_protocol_handler.cpp b/libs/internal/src/fdv2_protocol_handler.cpp index c07b7fbfe..e45f7d0be 100644 --- a/libs/internal/src/fdv2_protocol_handler.cpp +++ b/libs/internal/src/fdv2_protocol_handler.cpp @@ -73,11 +73,15 @@ FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleServerIntent( state_ = State::kFull; } else if (code == IntentCode::kTransferChanges) { state_ = State::kPartial; - } else { - // kNone or kUnknown: emit an empty changeset immediately. - state_ = State::kInactive; + } else if (code == IntentCode::kNone) { + state_ = State::kPartial; return data_model::FDv2ChangeSet{ data_model::ChangeSetType::kNone, {}, data_model::Selector{}}; + } else { + // kUnknown: an intent code we don't recognise. + Reset(); + return Error::ProtocolError( + "server-intent had an unrecognized intent code"); } return std::monostate{}; } diff --git a/libs/internal/tests/fdv2_protocol_handler_test.cpp b/libs/internal/tests/fdv2_protocol_handler_test.cpp index 9e0cc8e18..d97caf961 100644 --- a/libs/internal/tests/fdv2_protocol_handler_test.cpp +++ b/libs/internal/tests/fdv2_protocol_handler_test.cpp @@ -62,6 +62,37 @@ TEST(FDv2ProtocolHandlerTest, NoneIntentEmitsEmptyChangeSetImmediately) { EXPECT_FALSE(cs->selector.value.has_value()); } +TEST(FDv2ProtocolHandlerTest, NoneIntentAllowsSubsequentPartialCycle) { + FDv2ProtocolHandler handler; + + handler.HandleEvent("server-intent", MakeServerIntent("none")); + handler.HandleEvent("put-object", + MakePutObject("flag", "my-flag", kFlagJson)); + auto result = handler.HandleEvent("payload-transferred", + MakePayloadTransferred("s1", 1)); + + auto* cs = std::get_if(&result); + ASSERT_NE(cs, nullptr); + EXPECT_EQ(cs->type, data_model::ChangeSetType::kPartial); + ASSERT_EQ(cs->changes.size(), 1u); + EXPECT_EQ(cs->changes[0].key, "my-flag"); +} + +// ============================================================================ +// Unknown intent +// ============================================================================ + +TEST(FDv2ProtocolHandlerTest, UnknownIntentReturnsProtocolError) { + FDv2ProtocolHandler handler; + + auto result = + handler.HandleEvent("server-intent", MakeServerIntent("future-code")); + + auto* err = std::get_if(&result); + ASSERT_NE(err, nullptr); + EXPECT_EQ(err->kind, FDv2ProtocolHandler::Error::Kind::kProtocolError); +} + // ============================================================================ // kTransferFull intent // ============================================================================ From 13bdf20aeaefcf68fa17b95b265e586ac9b8b2be Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Tue, 16 Jun 2026 11:55:25 -0700 Subject: [PATCH 29/30] docs: correct DisableFDv1Fallback comment to match actual behavior --- .../server_side/config/builders/data_system/fdv2_builder.hpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp b/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp index 8d85d9ab4..c66c5dea7 100644 --- a/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp +++ b/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp @@ -108,8 +108,9 @@ class FDv2Builder { /** * @brief Disables the FDv1 fallback. After this call, an FDv1 - * fallback directive from the service transitions the SDK to - * OFFLINE rather than reconnecting via FDv1. + * fallback directive from the service leaves the data source in + * the interrupted state and schedules an FDv2 retry on the + * directive's TTL. * @return Reference to this. */ FDv2Builder& DisableFDv1Fallback(); From 45f59266226931f04122b316e0968b4c912dca62 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Tue, 16 Jun 2026 12:32:59 -0700 Subject: [PATCH 30/30] refactor: replace public FDv2Builder ctor with Custom() factory --- .../config/builders/data_system/fdv2_builder.hpp | 14 ++++++++------ .../config/builders/data_system/fdv2_builder.cpp | 4 ++++ libs/server-sdk/tests/config_builder_test.cpp | 15 ++++++++------- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp b/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp index c66c5dea7..69dcf32b6 100644 --- a/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp +++ b/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/fdv2_builder.hpp @@ -38,12 +38,6 @@ class FDv2Builder { std::optional base_url_override_; }; - /** - * Constructs a builder with no initializers, no synchronizers, and no - * FDv1 fallback. Use Default() for the spec-recommended configuration. - */ - FDv2Builder(); - /** * @return A builder pre-populated with the spec-recommended initializers, * synchronizers, and FDv1 fallback. Equivalent to calling @@ -52,6 +46,12 @@ class FDv2Builder { */ static FDv2Builder Default(); + /** + * @return A builder with no initializers, no synchronizers, and no + * FDv1 fallback. Callers must add sources explicitly. + */ + static FDv2Builder Custom(); + /** * @brief Appends a polling initializer to the initializers list. * @param source Polling source configuration for the initializer. @@ -137,6 +137,8 @@ class FDv2Builder { [[nodiscard]] built::FDv2Config Build() const; private: + FDv2Builder(); + built::FDv2Config config_; }; diff --git a/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp b/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp index 430e9d596..963ee924e 100644 --- a/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp +++ b/libs/server-sdk/src/config/builders/data_system/fdv2_builder.cpp @@ -59,6 +59,10 @@ FDv2Builder FDv2Builder::Default() { .FDv1Fallback(FDv1Streaming{}); } +FDv2Builder FDv2Builder::Custom() { + return FDv2Builder(); +} + FDv2Builder& FDv2Builder::Initializer(Polling source) { config_.initializers.push_back(source.Build()); return *this; diff --git a/libs/server-sdk/tests/config_builder_test.cpp b/libs/server-sdk/tests/config_builder_test.cpp index 52322437c..e07172d37 100644 --- a/libs/server-sdk/tests/config_builder_test.cpp +++ b/libs/server-sdk/tests/config_builder_test.cpp @@ -158,7 +158,7 @@ TEST_F(ConfigBuilderTest, FDv2_DefaultsAreUsed) { TEST_F(ConfigBuilderTest, FDv2_FDv1FallbackPolling) { ConfigBuilder builder("sdk-123"); builder.DataSystem().Method( - builders::DataSystemBuilder::FDv2().FDv1Fallback( + builders::DataSystemBuilder::FDv2::Custom().FDv1Fallback( builders::FDv2Builder::FDv1Polling().PollInterval( std::chrono::seconds{45}))); @@ -178,7 +178,7 @@ TEST_F(ConfigBuilderTest, FDv2_FDv1FallbackPolling) { TEST_F(ConfigBuilderTest, FDv2_MultipleSynchronizers) { ConfigBuilder builder("sdk-123"); builder.DataSystem().Method( - builders::DataSystemBuilder::FDv2() + builders::DataSystemBuilder::FDv2::Custom() .Synchronizer(builders::FDv2Builder::Polling().PollInterval( std::chrono::seconds{45})) .Synchronizer(builders::FDv2Builder::Streaming().Filter("filt"))); @@ -200,8 +200,9 @@ TEST_F(ConfigBuilderTest, FDv2_MultipleSynchronizers) { TEST_F(ConfigBuilderTest, FDv2_AddingInitializerClearsDefaults) { ConfigBuilder builder("sdk-123"); - builder.DataSystem().Method(builders::DataSystemBuilder::FDv2().Initializer( - builders::FDv2Builder::Polling().Filter("flag-subset"))); + builder.DataSystem().Method( + builders::DataSystemBuilder::FDv2::Custom().Initializer( + builders::FDv2Builder::Polling().Filter("flag-subset"))); auto cfg = builder.Build(); auto const fdv2_config = @@ -214,7 +215,7 @@ TEST_F(ConfigBuilderTest, FDv2_AddingInitializerClearsDefaults) { TEST_F(ConfigBuilderTest, FDv2_PerSourceBaseUrlOverride) { ConfigBuilder builder("sdk-123"); builder.DataSystem().Method( - builders::DataSystemBuilder::FDv2().Synchronizer( + builders::DataSystemBuilder::FDv2::Custom().Synchronizer( builders::FDv2Builder::Streaming().BaseUrl( "https://example.test"))); @@ -232,7 +233,7 @@ TEST_F(ConfigBuilderTest, FDv2_PerSourceBaseUrlOverride) { TEST_F(ConfigBuilderTest, FDv2_DisableFDv1FallbackClearsIt) { ConfigBuilder builder("sdk-123"); builder.DataSystem().Method( - builders::DataSystemBuilder::FDv2().DisableFDv1Fallback()); + builders::DataSystemBuilder::FDv2::Custom().DisableFDv1Fallback()); auto cfg = builder.Build(); auto const fdv2_config = @@ -243,7 +244,7 @@ TEST_F(ConfigBuilderTest, FDv2_DisableFDv1FallbackClearsIt) { TEST_F(ConfigBuilderTest, FDv2_FallbackAndRecoveryTimeouts) { ConfigBuilder builder("sdk-123"); - builder.DataSystem().Method(builders::DataSystemBuilder::FDv2() + builder.DataSystem().Method(builders::DataSystemBuilder::FDv2::Custom() .FallbackTimeout(std::chrono::seconds{30}) .RecoveryTimeout(std::chrono::seconds{90}));