diff --git a/contract-tests/data-model/include/data_model/data_model.hpp b/contract-tests/data-model/include/data_model/data_model.hpp index 637707c8e..38fd14021 100644 --- a/contract-tests/data-model/include/data_model/data_model.hpp +++ b/contract-tests/data-model/include/data_model/data_model.hpp @@ -424,6 +424,23 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(BigSegmentStoreStatusResponse, available, stale); +struct RegisterFlagChangeListenerParams { + std::string listenerId; + std::string callbackUri; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT( + RegisterFlagChangeListenerParams, + listenerId, + callbackUri); + +struct UnregisterListenerParams { + std::string listenerId; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(UnregisterListenerParams, + listenerId); + enum class Command { Unknown = -1, EvaluateFlag, @@ -433,20 +450,24 @@ enum class Command { FlushEvents, ContextBuild, ContextConvert, - GetBigSegmentStoreStatus -}; - -NLOHMANN_JSON_SERIALIZE_ENUM(Command, - {{Command::Unknown, nullptr}, - {Command::EvaluateFlag, "evaluate"}, - {Command::EvaluateAllFlags, "evaluateAll"}, - {Command::IdentifyEvent, "identifyEvent"}, - {Command::CustomEvent, "customEvent"}, - {Command::FlushEvents, "flushEvents"}, - {Command::ContextBuild, "contextBuild"}, - {Command::ContextConvert, "contextConvert"}, - {Command::GetBigSegmentStoreStatus, - "getBigSegmentStoreStatus"}}); + GetBigSegmentStoreStatus, + RegisterFlagChangeListener, + UnregisterListener +}; + +NLOHMANN_JSON_SERIALIZE_ENUM( + Command, + {{Command::Unknown, nullptr}, + {Command::EvaluateFlag, "evaluate"}, + {Command::EvaluateAllFlags, "evaluateAll"}, + {Command::IdentifyEvent, "identifyEvent"}, + {Command::CustomEvent, "customEvent"}, + {Command::FlushEvents, "flushEvents"}, + {Command::ContextBuild, "contextBuild"}, + {Command::ContextConvert, "contextConvert"}, + {Command::GetBigSegmentStoreStatus, "getBigSegmentStoreStatus"}, + {Command::RegisterFlagChangeListener, "registerFlagChangeListener"}, + {Command::UnregisterListener, "unregisterListener"}}); struct CommandParams { Command command; @@ -456,6 +477,8 @@ struct CommandParams { std::optional identifyEvent; std::optional contextBuild; std::optional contextConvert; + std::optional registerFlagChangeListener; + std::optional unregisterListener; CommandParams(); }; @@ -466,4 +489,6 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(CommandParams, customEvent, identifyEvent, contextBuild, - contextConvert); + contextConvert, + registerFlagChangeListener, + unregisterListener); diff --git a/contract-tests/server-contract-tests/CMakeLists.txt b/contract-tests/server-contract-tests/CMakeLists.txt index 5b78d4f03..45d28ee64 100644 --- a/contract-tests/server-contract-tests/CMakeLists.txt +++ b/contract-tests/server-contract-tests/CMakeLists.txt @@ -18,6 +18,7 @@ add_executable(server-tests src/client_entity.cpp src/contract_test_hook.cpp src/contract_test_big_segment_store.cpp + src/http_callback.cpp ) target_link_libraries(server-tests PRIVATE diff --git a/contract-tests/server-contract-tests/include/client_entity.hpp b/contract-tests/server-contract-tests/include/client_entity.hpp index f62c7f2a6..baaba5882 100644 --- a/contract-tests/server-contract-tests/include/client_entity.hpp +++ b/contract-tests/server-contract-tests/include/client_entity.hpp @@ -1,13 +1,19 @@ #pragma once #include +#include #include + +#include + #include +#include +#include class ClientEntity { public: - explicit ClientEntity( - std::unique_ptr client); + ClientEntity(std::unique_ptr client, + boost::asio::any_io_executor executor); tl::expected Command(CommandParams params); @@ -29,7 +35,18 @@ class ClientEntity { tl::expected GetBigSegmentStoreStatus(); + tl::expected RegisterFlagChangeListener( + RegisterFlagChangeListenerParams const&); + + tl::expected UnregisterListener( + UnregisterListenerParams const&); + + boost::asio::any_io_executor executor_; std::unique_ptr client_; + // Declared after client_ so listeners disconnect before the client is + // destroyed. + std::unordered_map> + listeners_; }; static tl::expected ContextConvert( diff --git a/contract-tests/server-contract-tests/include/http_callback.hpp b/contract-tests/server-contract-tests/include/http_callback.hpp new file mode 100644 index 000000000..b2d4941d1 --- /dev/null +++ b/contract-tests/server-contract-tests/include/http_callback.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +#include + +/** + * Synchronously POSTs a JSON payload to a test harness callback URI. Errors + * are ignored. Blocks the calling thread until the response is read, so + * sequential calls arrive at the harness in order. + * @param executor Executor used to construct the socket. + * @param callback_uri The URI to post to. + * @param payload The JSON body. + */ +void PostJsonCallback(boost::asio::any_io_executor executor, + std::string const& callback_uri, + nlohmann::json const& payload); diff --git a/contract-tests/server-contract-tests/src/client_entity.cpp b/contract-tests/server-contract-tests/src/client_entity.cpp index 9cd5c2f86..0201527e2 100644 --- a/contract-tests/server-contract-tests/src/client_entity.cpp +++ b/contract-tests/server-contract-tests/src/client_entity.cpp @@ -1,4 +1,5 @@ #include "client_entity.hpp" +#include "http_callback.hpp" #include #include @@ -40,8 +41,9 @@ tl::expected ParseContext( } ClientEntity::ClientEntity( - std::unique_ptr client) - : client_(std::move(client)) {} + std::unique_ptr client, + boost::asio::any_io_executor executor) + : executor_(std::move(executor)), client_(std::move(client)) {} tl::expected ClientEntity::Identify( IdentifyEventParams const& params) { @@ -359,6 +361,37 @@ tl::expected ClientEntity::Evaluate( return result; } +tl::expected +ClientEntity::RegisterFlagChangeListener( + RegisterFlagChangeListenerParams const& params) { + auto connection = client_->FlagNotifier().OnFlagChange( + [executor = executor_, listener_id = params.listenerId, + callback_uri = params.callbackUri](auto changes) { + for (auto const& flag_key : *changes) { + PostJsonCallback( + executor, callback_uri, + {{"listenerId", listener_id}, {"flagKey", flag_key}}); + } + }); + if (auto existing = listeners_.find(params.listenerId); + existing != listeners_.end()) { + existing->second->Disconnect(); + } + listeners_[params.listenerId] = std::move(connection); + return nlohmann::json{}; +} + +tl::expected ClientEntity::UnregisterListener( + UnregisterListenerParams const& params) { + auto it = listeners_.find(params.listenerId); + if (it == listeners_.end()) { + return tl::make_unexpected("unknown listener: " + params.listenerId); + } + it->second->Disconnect(); + listeners_.erase(it); + return nlohmann::json{}; +} + tl::expected ClientEntity::Command( CommandParams params) { switch (params.command) { @@ -399,6 +432,19 @@ tl::expected ClientEntity::Command( return ContextConvert(*params.contextConvert); case Command::GetBigSegmentStoreStatus: return GetBigSegmentStoreStatus(); + case Command::RegisterFlagChangeListener: + if (!params.registerFlagChangeListener) { + return tl::make_unexpected( + "registerFlagChangeListener params must be set"); + } + return RegisterFlagChangeListener( + *params.registerFlagChangeListener); + case Command::UnregisterListener: + if (!params.unregisterListener) { + return tl::make_unexpected( + "unregisterListener params must be set"); + } + return UnregisterListener(*params.unregisterListener); } return tl::make_unexpected("unrecognized command"); } diff --git a/contract-tests/server-contract-tests/src/contract_test_hook.cpp b/contract-tests/server-contract-tests/src/contract_test_hook.cpp index c143bb9f1..3af190eaf 100644 --- a/contract-tests/server-contract-tests/src/contract_test_hook.cpp +++ b/contract-tests/server-contract-tests/src/contract_test_hook.cpp @@ -1,22 +1,11 @@ #include "contract_test_hook.hpp" +#include "http_callback.hpp" #include #include #include #include -#include -#include -#include -#include -#include -#include - -namespace beast = boost::beast; -namespace http = beast::http; -namespace net = boost::asio; -using tcp = net::ip::tcp; - using namespace launchdarkly::server_side::hooks; ContractTestHook::ContractTestHook(boost::asio::any_io_executor executor, @@ -55,52 +44,7 @@ std::optional ContractTestHook::GetErrorForStage( void ContractTestHook::PostCallback(std::string const& stage, nlohmann::json const& payload) { - auto uri_result = boost::urls::parse_uri(config_.callbackUri); - if (!uri_result) { - return; - } - - auto uri = *uri_result; - std::string host(uri.host()); - std::string port = uri.has_port() ? std::string(uri.port()) : "80"; - std::string target(uri.path()); - if (target.empty()) { - target = "/"; - } - - // Send synchronously so callbacks from sequential hook invocations arrive - // at the harness in the order the SDK invoked them. - beast::error_code ec; - tcp::resolver resolver(executor_); - auto results = resolver.resolve(host, port, ec); - if (ec) { - return; - } - - beast::tcp_stream stream(executor_); - stream.connect(results, ec); - if (ec) { - return; - } - - http::request req{http::verb::post, target, 11}; - req.set(http::field::host, host); - req.set(http::field::user_agent, "cpp-server-sdk-contract-tests"); - req.set(http::field::content_type, "application/json"); - req.body() = payload.dump(); - req.prepare_payload(); - - http::write(stream, req, ec); - if (ec) { - return; - } - - beast::flat_buffer buffer; - http::response res; - http::read(stream, buffer, res, ec); - - beast::error_code ignore_ec; - stream.socket().shutdown(tcp::socket::shutdown_both, ignore_ec); + PostJsonCallback(executor_, config_.callbackUri, payload); } EvaluationSeriesData ContractTestHook::BeforeEvaluation( diff --git a/contract-tests/server-contract-tests/src/entity_manager.cpp b/contract-tests/server-contract-tests/src/entity_manager.cpp index a8bca84b6..66ab0965d 100644 --- a/contract-tests/server-contract-tests/src/entity_manager.cpp +++ b/contract-tests/server-contract-tests/src/entity_manager.cpp @@ -424,7 +424,7 @@ std::optional EntityManager::create(ConfigParams const& in) { auto init = client->StartAsync(); init.wait_for(waitForClient); - entities_.try_emplace(id, std::move(client)); + entities_.try_emplace(id, std::move(client), executor_); return id; } diff --git a/contract-tests/server-contract-tests/src/http_callback.cpp b/contract-tests/server-contract-tests/src/http_callback.cpp new file mode 100644 index 000000000..b9c65a23d --- /dev/null +++ b/contract-tests/server-contract-tests/src/http_callback.cpp @@ -0,0 +1,61 @@ +#include "http_callback.hpp" + +#include +#include +#include +#include +#include + +namespace beast = boost::beast; +namespace http = beast::http; +namespace net = boost::asio; +using tcp = net::ip::tcp; + +void PostJsonCallback(boost::asio::any_io_executor executor, + std::string const& callback_uri, + nlohmann::json const& payload) { + auto uri_result = boost::urls::parse_uri(callback_uri); + if (!uri_result) { + return; + } + + auto uri = *uri_result; + std::string host(uri.host()); + std::string port = uri.has_port() ? std::string(uri.port()) : "80"; + std::string target(uri.path()); + if (target.empty()) { + target = "/"; + } + + beast::error_code ec; + tcp::resolver resolver(executor); + auto results = resolver.resolve(host, port, ec); + if (ec) { + return; + } + + beast::tcp_stream stream(executor); + stream.connect(results, ec); + if (ec) { + return; + } + + http::request req{http::verb::post, target, 11}; + req.set(http::field::host, host); + req.set(http::field::user_agent, "cpp-server-sdk-contract-tests"); + req.set(http::field::content_type, "application/json"); + req.body() = payload.dump(); + req.prepare_payload(); + + http::write(stream, req, ec); + if (ec) { + return; + } + + beast::flat_buffer buffer; + http::response res; + http::read(stream, buffer, res, ec); + + beast::error_code ignore_ec; + stream.socket().shutdown(tcp::socket::shutdown_both, ignore_ec); +} diff --git a/contract-tests/server-contract-tests/src/main.cpp b/contract-tests/server-contract-tests/src/main.cpp index 6551699c6..642aa93ed 100644 --- a/contract-tests/server-contract-tests/src/main.cpp +++ b/contract-tests/server-contract-tests/src/main.cpp @@ -54,6 +54,7 @@ int main(int argc, char* argv[]) { srv.add_capability("instance-id"); srv.add_capability("fdv1-fallback"); srv.add_capability("big-segments"); + srv.add_capability("flag-change-listeners"); #ifdef LD_REDIS_SUPPORT_ENABLED srv.add_capability("persistent-data-store-redis"); #endif diff --git a/libs/server-sdk/include/launchdarkly/server_side/client.hpp b/libs/server-sdk/include/launchdarkly/server_side/client.hpp index 20847fe39..71fe6ab9c 100644 --- a/libs/server-sdk/include/launchdarkly/server_side/client.hpp +++ b/libs/server-sdk/include/launchdarkly/server_side/client.hpp @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -509,6 +510,20 @@ class IClient { */ virtual IBigSegmentStoreStatusProvider& BigSegmentStoreStatus() = 0; + /** + * Returns an interface for registering listeners that are notified when + * flag configurations change. A flag is reported as changed when its own + * configuration changes, or when a flag or segment it depends on changes. + * + * Listeners are called after the change has been applied, so + * evaluations made from a listener use the updated data. Listeners are + * never called when the SDK is offline or uses a lazy load data system. + * + * @return A change notifier which remains valid for the lifetime of the + * client. + */ + virtual IChangeNotifier& FlagNotifier() = 0; + virtual ~IClient() = default; IClient(IClient const& item) = delete; IClient(IClient&& item) = delete; @@ -663,6 +678,8 @@ class Client : public IClient { IBigSegmentStoreStatusProvider& BigSegmentStoreStatus() override; + IChangeNotifier& FlagNotifier() override; + /** * Returns the version of the SDK. * @return String representing version of the SDK. diff --git a/libs/server-sdk/src/CMakeLists.txt b/libs/server-sdk/src/CMakeLists.txt index ff2d7d75d..f1a4975a7 100644 --- a/libs/server-sdk/src/CMakeLists.txt +++ b/libs/server-sdk/src/CMakeLists.txt @@ -42,6 +42,7 @@ target_sources(${LIBNAME} prereq_event_recorder/prereq_event_recorder.hpp data_components/change_notifier/change_notifier.hpp data_components/change_notifier/change_notifier.cpp + data_components/change_notifier/null_change_notifier.hpp data_components/dependency_tracker/dependency_tracker.hpp data_components/dependency_tracker/dependency_tracker.cpp data_components/expiration_tracker/expiration_tracker.hpp diff --git a/libs/server-sdk/src/client.cpp b/libs/server-sdk/src/client.cpp index 6920f11d6..bdbcb1c53 100644 --- a/libs/server-sdk/src/client.cpp +++ b/libs/server-sdk/src/client.cpp @@ -233,6 +233,10 @@ IBigSegmentStoreStatusProvider& Client::BigSegmentStoreStatus() { return client->BigSegmentStoreStatus(); } +IChangeNotifier& Client::FlagNotifier() { + return client->FlagNotifier(); +} + char const* Client::Version() { return kVersion; } diff --git a/libs/server-sdk/src/client_impl.cpp b/libs/server-sdk/src/client_impl.cpp index 098921ad1..85d305f29 100644 --- a/libs/server-sdk/src/client_impl.cpp +++ b/libs/server-sdk/src/client_impl.cpp @@ -784,6 +784,10 @@ IBigSegmentStoreStatusProvider& ClientImpl::BigSegmentStoreStatus() { return big_segment_status_provider_; } +IChangeNotifier& ClientImpl::FlagNotifier() { + return data_system_->FlagNotifier(); +} + ClientImpl::~ClientImpl() { ioc_.stop(); // TODO(SC-219101) diff --git a/libs/server-sdk/src/client_impl.hpp b/libs/server-sdk/src/client_impl.hpp index 8466a682f..143707e88 100644 --- a/libs/server-sdk/src/client_impl.hpp +++ b/libs/server-sdk/src/client_impl.hpp @@ -179,6 +179,8 @@ class ClientImpl : public IClient { IBigSegmentStoreStatusProvider& BigSegmentStoreStatus() override; + IChangeNotifier& FlagNotifier() override; + ~ClientImpl(); std::future StartAsync() override; diff --git a/libs/server-sdk/src/data_components/change_notifier/null_change_notifier.hpp b/libs/server-sdk/src/data_components/change_notifier/null_change_notifier.hpp new file mode 100644 index 000000000..dacbcd09a --- /dev/null +++ b/libs/server-sdk/src/data_components/change_notifier/null_change_notifier.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +#include + +namespace launchdarkly::server_side::data_components { + +/** + * A change notifier for data systems that never observe flag changes. Handlers + * are accepted but never called. + * + * Thread-safe: holds no state. + */ +class NullChangeNotifier final : public IChangeNotifier { + public: + NullChangeNotifier() = default; + + std::unique_ptr OnFlagChange(ChangeHandler handler) override { + return std::make_unique< + launchdarkly::internal::signals::SignalConnection>( + boost::signals2::connection()); + } +}; + +} // namespace launchdarkly::server_side::data_components diff --git a/libs/server-sdk/src/data_components/dependency_tracker/dependency_tracker.cpp b/libs/server-sdk/src/data_components/dependency_tracker/dependency_tracker.cpp index 0a3338ac6..3e0c7516f 100644 --- a/libs/server-sdk/src/data_components/dependency_tracker/dependency_tracker.cpp +++ b/libs/server-sdk/src/data_components/dependency_tracker/dependency_tracker.cpp @@ -63,8 +63,9 @@ DependencyMap::DependencyMap() } {} void DependencyMap::Set(DataKind kind, std::string key, DependencySet val) { - data_[static_cast>(kind)].Data().emplace( - std::move(key), std::move(val)); + data_[static_cast>(kind)] + .Data() + .insert_or_assign(std::move(key), std::move(val)); } std::optional DependencyMap::Get(DataKind kind, @@ -155,7 +156,9 @@ void DependencyTracker::UpdateDependencies(DataKind kind, for (auto const& dep : deps_by_kind.Data()) { auto deps_to_this_dep = dependencies_to_.Get(kind_of_dep, dep); if (deps_to_this_dep) { - deps_to_this_dep->Remove(kind_of_dep, key); + deps_to_this_dep->Remove(kind, key); + dependencies_to_.Set(kind_of_dep, dep, + std::move(*deps_to_this_dep)); } } } @@ -165,15 +168,11 @@ void DependencyTracker::UpdateDependencies(DataKind kind, for (auto const& deps_by_kind : deps) { for (auto const& dep : deps_by_kind.Data()) { auto deps_to_this_dep = - dependencies_to_.Get(deps_by_kind.Kind(), dep); - if (!deps_to_this_dep) { - auto new_deps_to_this_dep = DependencySet(); - new_deps_to_this_dep.Set(kind, key); - dependencies_to_.Set(deps_by_kind.Kind(), dep, - new_deps_to_this_dep); - } else { - deps_to_this_dep->Set(kind, key); - } + dependencies_to_.Get(deps_by_kind.Kind(), dep) + .value_or(DependencySet()); + deps_to_this_dep.Set(kind, key); + dependencies_to_.Set(deps_by_kind.Kind(), dep, + std::move(deps_to_this_dep)); } } } diff --git a/libs/server-sdk/src/data_interfaces/system/idata_system.hpp b/libs/server-sdk/src/data_interfaces/system/idata_system.hpp index 0edf778db..0f7908732 100644 --- a/libs/server-sdk/src/data_interfaces/system/idata_system.hpp +++ b/libs/server-sdk/src/data_interfaces/system/idata_system.hpp @@ -2,6 +2,8 @@ #include "../store/istore.hpp" +#include + namespace launchdarkly::server_side::data_interfaces { /** @@ -21,6 +23,12 @@ class IDataSystem : public IStore { */ virtual void Initialize() = 0; + /** + * @return Notifier for flag configuration changes. Valid for the lifetime + * of the system. + */ + virtual IChangeNotifier& FlagNotifier() = 0; + virtual ~IDataSystem() override = default; IDataSystem(IDataSystem const& item) = delete; IDataSystem(IDataSystem&& item) = delete; diff --git a/libs/server-sdk/src/data_systems/background_sync/background_sync_system.cpp b/libs/server-sdk/src/data_systems/background_sync/background_sync_system.cpp index 1a6de5c0a..577e33cad 100644 --- a/libs/server-sdk/src/data_systems/background_sync/background_sync_system.cpp +++ b/libs/server-sdk/src/data_systems/background_sync/background_sync_system.cpp @@ -42,6 +42,10 @@ bool BackgroundSync::Initialized() const { return store_.Initialized(); } +IChangeNotifier& BackgroundSync::FlagNotifier() { + return change_notifier_; +} + std::string const& BackgroundSync::Identity() const { static std::string id = "background sync via " + synchronizer_->Identity(); return id; diff --git a/libs/server-sdk/src/data_systems/background_sync/background_sync_system.hpp b/libs/server-sdk/src/data_systems/background_sync/background_sync_system.hpp index a302b7d0d..81d04638e 100644 --- a/libs/server-sdk/src/data_systems/background_sync/background_sync_system.hpp +++ b/libs/server-sdk/src/data_systems/background_sync/background_sync_system.hpp @@ -55,6 +55,8 @@ class BackgroundSync final : public data_interfaces::IDataSystem { bool Initialized() const override; + IChangeNotifier& FlagNotifier() override; + private: data_components::MemoryStore store_; data_components::ChangeNotifier change_notifier_; 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 e5b3a52e0..c4b94f6f7 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 @@ -496,4 +496,8 @@ bool FDv2DataSystem::Initialized() const { return store_.Initialized(); } +IChangeNotifier& FDv2DataSystem::FlagNotifier() { + return change_notifier_; +} + } // namespace launchdarkly::server_side::data_systems 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 55116c0d3..2db60d0ac 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 @@ -237,6 +237,12 @@ class FDv2DataSystem final : public data_interfaces::IDataSystem { */ bool Initialized() const override; + /** + * Returns the notifier for flag configuration changes applied by + * initializers and synchronizers. + */ + IChangeNotifier& FlagNotifier() override; + private: /** * Signals the orchestration loop to stop and closes any active source. diff --git a/libs/server-sdk/src/data_systems/lazy_load/lazy_load_system.cpp b/libs/server-sdk/src/data_systems/lazy_load/lazy_load_system.cpp index 9f5d24b5d..15713e838 100644 --- a/libs/server-sdk/src/data_systems/lazy_load/lazy_load_system.cpp +++ b/libs/server-sdk/src/data_systems/lazy_load/lazy_load_system.cpp @@ -146,6 +146,10 @@ bool LazyLoad::Initialized() const { return true; } +IChangeNotifier& LazyLoad::FlagNotifier() { + return change_notifier_; +} + void LazyLoad::RefreshAllFlags() const { RefreshAll(Keys::kAllFlags, data_components::DataKind::kFlag, diff --git a/libs/server-sdk/src/data_systems/lazy_load/lazy_load_system.hpp b/libs/server-sdk/src/data_systems/lazy_load/lazy_load_system.hpp index 5f1c5644a..ab4d63e93 100644 --- a/libs/server-sdk/src/data_systems/lazy_load/lazy_load_system.hpp +++ b/libs/server-sdk/src/data_systems/lazy_load/lazy_load_system.hpp @@ -1,6 +1,7 @@ #pragma once #include "../../../include/launchdarkly/server_side/integrations/data_reader/kinds.hpp" +#include "../../data_components/change_notifier/null_change_notifier.hpp" #include "../../data_components/expiration_tracker/expiration_tracker.hpp" #include "../../data_components/memory_store/memory_store.hpp" #include "../../data_components/status_notifications/data_source_status_manager.hpp" @@ -58,6 +59,8 @@ class LazyLoad final : public data_interfaces::IDataSystem { bool Initialized() const override; + IChangeNotifier& FlagNotifier() override; + // Public for usage in tests. struct Kinds { static integrations::FlagKind const Flag; @@ -182,6 +185,7 @@ class LazyLoad final : public data_interfaces::IDataSystem { std::unique_ptr reader_; data_components::DataSourceStatusManager& status_manager_; + data_components::NullChangeNotifier change_notifier_; mutable data_components::ExpirationTracker tracker_; TimeFn time_; diff --git a/libs/server-sdk/src/data_systems/offline.cpp b/libs/server-sdk/src/data_systems/offline.cpp index 148c10a2d..0ada3faf8 100644 --- a/libs/server-sdk/src/data_systems/offline.cpp +++ b/libs/server-sdk/src/data_systems/offline.cpp @@ -41,4 +41,8 @@ std::string const& OfflineSystem::Identity() const { return ident; } +IChangeNotifier& OfflineSystem::FlagNotifier() { + return change_notifier_; +} + } // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/offline.hpp b/libs/server-sdk/src/data_systems/offline.hpp index c9355bd24..5f444562a 100644 --- a/libs/server-sdk/src/data_systems/offline.hpp +++ b/libs/server-sdk/src/data_systems/offline.hpp @@ -1,5 +1,6 @@ #pragma once +#include "../data_components/change_notifier/null_change_notifier.hpp" #include "../data_components/status_notifications/data_source_status_manager.hpp" #include "../data_interfaces/system/idata_system.hpp" @@ -22,9 +23,11 @@ class OfflineSystem final : public data_interfaces::IDataSystem { [[nodiscard]] bool Initialized() const override; [[nodiscard]] std::string const& Identity() const override; void Initialize() override; + IChangeNotifier& FlagNotifier() override; private: data_components::DataSourceStatusManager& status_manager_; + data_components::NullChangeNotifier change_notifier_; }; } // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/tests/dependency_tracker_test.cpp b/libs/server-sdk/tests/dependency_tracker_test.cpp index 797e3a45e..54561572c 100644 --- a/libs/server-sdk/tests/dependency_tracker_test.cpp +++ b/libs/server-sdk/tests/dependency_tracker_test.cpp @@ -74,6 +74,21 @@ TEST(ScopedMapTest, CanGetItem) { ->Contains(DataKind::kSegment, "segmentA")); } +TEST(ScopedMapTest, SetReplacesExistingItem) { + DependencyMap map; + DependencySet first; + first.Set(DataKind::kSegment, "segmentA"); + DependencySet second; + second.Set(DataKind::kSegment, "segmentB"); + + map.Set(DataKind::kFlag, "flagA", first); + map.Set(DataKind::kFlag, "flagA", second); + + auto deps = map.Get(DataKind::kFlag, "flagA"); + EXPECT_FALSE(deps->Contains(DataKind::kSegment, "segmentA")); + EXPECT_TRUE(deps->Contains(DataKind::kSegment, "segmentB")); +} + TEST(ScopedMapTest, CanIterate) { DependencyMap map; @@ -287,3 +302,91 @@ TEST(DependencyTrackerTest, HandlesUpdateForSomethingThatDoesNotExist) { EXPECT_EQ(1, changes.Size()); EXPECT_TRUE(changes.Contains(DataKind::kFlag, "potato")); } + +TEST(DependencyTrackerTest, FollowsDependencyAddedToItemWithDependents) { + DependencyTracker tracker; + + Flag flag_a; + flag_a.key = "flagA"; + flag_a.version = 1; + + Flag flag_b; + flag_b.key = "flagB"; + flag_b.version = 1; + flag_b.prerequisites.push_back(Flag::Prerequisite{"flagA", 0}); + + Flag flag_c; + flag_c.key = "flagC"; + flag_c.version = 1; + + tracker.UpdateDependencies("flagA", FlagDescriptor(flag_a)); + tracker.UpdateDependencies("flagB", FlagDescriptor(flag_b)); + tracker.UpdateDependencies("flagC", FlagDescriptor(flag_c)); + + // flagC starts depending on flagA, which already has flagB as a dependent. + flag_c.version = 2; + flag_c.prerequisites.push_back(Flag::Prerequisite{"flagA", 0}); + tracker.UpdateDependencies("flagC", FlagDescriptor(flag_c)); + + DependencySet changes; + tracker.CalculateChanges(DataKind::kFlag, "flagA", changes); + + EXPECT_TRUE(changes.Contains(DataKind::kFlag, "flagA")); + EXPECT_TRUE(changes.Contains(DataKind::kFlag, "flagB")); + EXPECT_TRUE(changes.Contains(DataKind::kFlag, "flagC")); + EXPECT_EQ(3, changes.Size()); +} + +TEST(DependencyTrackerTest, StopsFollowingRemovedDependency) { + DependencyTracker tracker; + + Flag flag_a; + flag_a.key = "flagA"; + flag_a.version = 1; + + Flag flag_b; + flag_b.key = "flagB"; + flag_b.version = 1; + flag_b.prerequisites.push_back(Flag::Prerequisite{"flagA", 0}); + + tracker.UpdateDependencies("flagA", FlagDescriptor(flag_a)); + tracker.UpdateDependencies("flagB", FlagDescriptor(flag_b)); + + flag_b.version = 2; + flag_b.prerequisites.clear(); + tracker.UpdateDependencies("flagB", FlagDescriptor(flag_b)); + + DependencySet changes; + tracker.CalculateChanges(DataKind::kFlag, "flagA", changes); + + EXPECT_TRUE(changes.Contains(DataKind::kFlag, "flagA")); + EXPECT_EQ(1, changes.Size()); +} + +TEST(DependencyTrackerTest, StopsFollowingRemovedSegmentDependency) { + DependencyTracker tracker; + + Segment segment_a; + segment_a.key = "segmentA"; + segment_a.version = 1; + + Flag flag_a; + flag_a.key = "flagA"; + flag_a.version = 1; + flag_a.rules.push_back(Flag::Rule{std::vector{ + Clause{Clause::Op::kSegmentMatch, std::vector{"segmentA"}, false, + ContextKind("user"), AttributeReference("key")}}}); + + tracker.UpdateDependencies("segmentA", SegmentDescriptor(segment_a)); + tracker.UpdateDependencies("flagA", FlagDescriptor(flag_a)); + + flag_a.version = 2; + flag_a.rules.clear(); + tracker.UpdateDependencies("flagA", FlagDescriptor(flag_a)); + + DependencySet changes; + tracker.CalculateChanges(DataKind::kSegment, "segmentA", changes); + + EXPECT_TRUE(changes.Contains(DataKind::kSegment, "segmentA")); + EXPECT_EQ(1, changes.Size()); +}