From abbc1915eb778c10b2a417d1510b3e2e11c9076b Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Wed, 3 Jun 2026 18:09:56 -0700 Subject: [PATCH 1/5] feat: wire FDv2 dataSystem into server contract tests --- .../include/data_model/data_model.hpp | 42 ++++- .../src/entity_manager.cpp | 156 ++++++++++++++---- .../server-contract-tests/src/main.cpp | 1 + 3 files changed, 157 insertions(+), 42 deletions(-) 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 679fd0015..872e91208 100644 --- a/contract-tests/data-model/include/data_model/data_model.hpp +++ b/contract-tests/data-model/include/data_model/data_model.hpp @@ -113,11 +113,7 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigTags, applicationId, applicationVersion); -enum class HookStage { - BeforeEvaluation, - AfterEvaluation, - AfterTrack -}; +enum class HookStage { BeforeEvaluation, AfterEvaluation, AfterTrack }; NLOHMANN_JSON_SERIALIZE_ENUM(HookStage, {{HookStage::BeforeEvaluation, "beforeEvaluation"}, @@ -127,7 +123,10 @@ NLOHMANN_JSON_SERIALIZE_ENUM(HookStage, struct ConfigHookInstance { std::string name; std::string callbackUri; - std::optional>> data; + std::optional< + std::unordered_map>> + data; std::optional> errors; }; @@ -150,12 +149,42 @@ struct ConfigWrapper { NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigWrapper, name, version); +struct ConfigDataSynchronizerParams { + std::optional streaming; + std::optional polling; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigDataSynchronizerParams, + streaming, + polling); + +struct ConfigDataInitializerParams { + std::optional polling; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigDataInitializerParams, + polling); + +struct ConfigDataSystemParams { + std::optional> initializers; + std::optional> synchronizers; + std::optional fdv1Fallback; + std::optional payloadFilter; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigDataSystemParams, + initializers, + synchronizers, + fdv1Fallback, + payloadFilter); + struct ConfigParams { std::string credential; std::optional startWaitTimeMs; std::optional initCanFail; std::optional streaming; std::optional polling; + std::optional dataSystem; std::optional events; std::optional serviceEndpoints; std::optional clientSide; @@ -172,6 +201,7 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigParams, initCanFail, streaming, polling, + dataSystem, events, serviceEndpoints, clientSide, diff --git a/contract-tests/server-contract-tests/src/entity_manager.cpp b/contract-tests/server-contract-tests/src/entity_manager.cpp index 085df1c2d..6cab18936 100644 --- a/contract-tests/server-contract-tests/src/entity_manager.cpp +++ b/contract-tests/server-contract-tests/src/entity_manager.cpp @@ -10,48 +10,84 @@ using launchdarkly::LogLevel; using namespace launchdarkly::server_side; -EntityManager::EntityManager(boost::asio::any_io_executor executor, - launchdarkly::Logger& logger) - : counter_{0}, executor_{std::move(executor)}, logger_{logger} {} - -std::optional EntityManager::create(ConfigParams const& in) { - std::string id = std::to_string(counter_++); - - auto config_builder = ConfigBuilder(in.credential); - - // The contract test service sets endpoints in a way that is disallowed - // for users. Specifically, it may set just 1 of the 3 endpoints, whereas - // we require all 3 to be set. - // - // To avoid that error being detected, we must configure the Endpoints - // builder with the 3 default URLs, which we can fetch by just calling Build - // on a new builder. That way when the contract tests set just 1 URL, - // the others have already been "set" so no error occurs. - auto const default_endpoints = - *config::builders::EndpointsBuilder().Build(); - - auto& endpoints = - config_builder.ServiceEndpoints() - .EventsBaseUrl(default_endpoints.EventsBaseUrl()) - .PollingBaseUrl(default_endpoints.PollingBaseUrl()) - .StreamingBaseUrl(default_endpoints.StreamingBaseUrl()); +namespace { + +config::builders::DataSystemBuilder::FDv2 BuildFDv2( + ConfigDataSystemParams const& cfg) { + auto fdv2 = config::builders::DataSystemBuilder::FDv2(); + + if (cfg.synchronizers) { + for (auto const& sync : *cfg.synchronizers) { + if (sync.streaming) { + auto s = decltype(fdv2)::Streaming(); + if (sync.streaming->baseUri) { + s.BaseUrl(*sync.streaming->baseUri); + } + if (sync.streaming->initialRetryDelayMs) { + s.InitialReconnectDelay(std::chrono::milliseconds( + *sync.streaming->initialRetryDelayMs)); + } + if (cfg.payloadFilter) { + s.Filter(*cfg.payloadFilter); + } + fdv2.Synchronizer(std::move(s)); + } else if (sync.polling) { + auto p = decltype(fdv2)::Polling(); + if (sync.polling->baseUri) { + p.BaseUrl(*sync.polling->baseUri); + } + if (sync.polling->pollIntervalMs) { + p.PollInterval( + std::chrono::duration_cast( + std::chrono::milliseconds( + *sync.polling->pollIntervalMs))); + } + if (cfg.payloadFilter) { + p.Filter(*cfg.payloadFilter); + } + fdv2.Synchronizer(std::move(p)); + } + } + } - if (in.serviceEndpoints) { - if (in.serviceEndpoints->streaming) { - endpoints.StreamingBaseUrl(*in.serviceEndpoints->streaming); + if (cfg.initializers) { + for (auto const& init : *cfg.initializers) { + if (init.polling) { + auto p = decltype(fdv2)::Polling(); + if (init.polling->baseUri) { + p.BaseUrl(*init.polling->baseUri); + } + if (cfg.payloadFilter) { + p.Filter(*cfg.payloadFilter); + } + fdv2.Initializer(std::move(p)); + } } - if (in.serviceEndpoints->polling) { - endpoints.PollingBaseUrl(*in.serviceEndpoints->polling); + } + + if (cfg.fdv1Fallback) { + auto p = decltype(fdv2)::Polling(); + if (cfg.fdv1Fallback->baseUri) { + p.BaseUrl(*cfg.fdv1Fallback->baseUri); } - if (in.serviceEndpoints->events) { - endpoints.EventsBaseUrl(*in.serviceEndpoints->events); + if (cfg.fdv1Fallback->pollIntervalMs) { + p.PollInterval(std::chrono::duration_cast( + std::chrono::milliseconds(*cfg.fdv1Fallback->pollIntervalMs))); } + fdv2.FDv1Fallback(std::move(p)); } + + return fdv2; +} + +config::builders::DataSystemBuilder::BackgroundSync BuildBackgroundSync( + ConfigParams const& in, + config::builders::EndpointsBuilder* endpoints) { auto datasystem = config::builders::DataSystemBuilder::BackgroundSync(); if (in.streaming) { if (in.streaming->baseUri) { - endpoints.StreamingBaseUrl(*in.streaming->baseUri); + endpoints->StreamingBaseUrl(*in.streaming->baseUri); } auto streaming = decltype(datasystem)::Streaming(); if (in.streaming->initialRetryDelayMs) { @@ -66,7 +102,7 @@ std::optional EntityManager::create(ConfigParams const& in) { if (in.polling) { if (in.polling->baseUri) { - endpoints.PollingBaseUrl(*in.polling->baseUri); + endpoints->PollingBaseUrl(*in.polling->baseUri); } if (!in.streaming) { auto method = decltype(datasystem)::Polling(); @@ -83,7 +119,54 @@ std::optional EntityManager::create(ConfigParams const& in) { } } - config_builder.DataSystem().Method(std::move(datasystem)); + return datasystem; +} + +} // namespace + +EntityManager::EntityManager(boost::asio::any_io_executor executor, + launchdarkly::Logger& logger) + : counter_{0}, executor_{std::move(executor)}, logger_{logger} {} + +std::optional EntityManager::create(ConfigParams const& in) { + std::string id = std::to_string(counter_++); + + auto config_builder = ConfigBuilder(in.credential); + + // The contract test service sets endpoints in a way that is disallowed + // for users. Specifically, it may set just 1 of the 3 endpoints, whereas + // we require all 3 to be set. + // + // To avoid that error being detected, we must configure the Endpoints + // builder with the 3 default URLs, which we can fetch by just calling Build + // on a new builder. That way when the contract tests set just 1 URL, + // the others have already been "set" so no error occurs. + auto const default_endpoints = + *config::builders::EndpointsBuilder().Build(); + + auto& endpoints = + config_builder.ServiceEndpoints() + .EventsBaseUrl(default_endpoints.EventsBaseUrl()) + .PollingBaseUrl(default_endpoints.PollingBaseUrl()) + .StreamingBaseUrl(default_endpoints.StreamingBaseUrl()); + + if (in.serviceEndpoints) { + if (in.serviceEndpoints->streaming) { + endpoints.StreamingBaseUrl(*in.serviceEndpoints->streaming); + } + if (in.serviceEndpoints->polling) { + endpoints.PollingBaseUrl(*in.serviceEndpoints->polling); + } + if (in.serviceEndpoints->events) { + endpoints.EventsBaseUrl(*in.serviceEndpoints->events); + } + } + + if (in.dataSystem) { + config_builder.DataSystem().Method(BuildFDv2(*in.dataSystem)); + } else { + config_builder.DataSystem().Method(BuildBackgroundSync(in, &endpoints)); + } auto& event_config = config_builder.Events(); @@ -140,7 +223,8 @@ std::optional EntityManager::create(ConfigParams const& in) { if (in.hooks) { for (auto const& hook_config : in.hooks->hooks) { - auto hook = std::make_shared(executor_, hook_config); + auto hook = + std::make_shared(executor_, hook_config); config_builder.Hooks(hook); } } diff --git a/contract-tests/server-contract-tests/src/main.cpp b/contract-tests/server-contract-tests/src/main.cpp index d81d12081..f77a0de9e 100644 --- a/contract-tests/server-contract-tests/src/main.cpp +++ b/contract-tests/server-contract-tests/src/main.cpp @@ -52,6 +52,7 @@ int main(int argc, char* argv[]) { srv.add_capability("track-hooks"); srv.add_capability("wrapper"); srv.add_capability("instance-id"); + srv.add_capability("fdv1-fallback"); net::signal_set signals{ioc, SIGINT, SIGTERM}; From 0469d55ea66d3ef0a43ac9ff44694037c9e9e6b6 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Wed, 3 Jun 2026 18:11:42 -0700 Subject: [PATCH 2/5] test: add v3 contract test harness suppression file --- .../test-suppressions-fdv2.txt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 contract-tests/server-contract-tests/test-suppressions-fdv2.txt diff --git a/contract-tests/server-contract-tests/test-suppressions-fdv2.txt b/contract-tests/server-contract-tests/test-suppressions-fdv2.txt new file mode 100644 index 000000000..7db130738 --- /dev/null +++ b/contract-tests/server-contract-tests/test-suppressions-fdv2.txt @@ -0,0 +1,18 @@ +events/summary events/flag versions +streaming/requests/URL path is computed correctly/environment_filter_key="encoding necessary +! %& ( )"/base URI has no trailing slash/GET +streaming/requests/URL path is computed correctly/environment_filter_key="encoding necessary +! %& ( )"/base URI has a trailing slash/GET +streaming/validation/drop and reconnect if stream event has malformed JSON/server-intent event +streaming/validation/drop and reconnect if stream event has well-formed JSON not matching schema/server-intent event +streaming/validation/unrecognized data that can be safely ignored/unknown event name with JSON body +streaming/validation/unrecognized data that can be safely ignored/unknown event name with non-JSON body +streaming/validation/unrecognized data that can be safely ignored/patch event with unrecognized path kind +streaming/fdv2/updates are not complete until payload transferred is sent +streaming/fdv2/handles multiple updates +streaming/fdv2/ignores model version +streaming/fdv2/ignores heart beat +streaming/fdv2/can discard partial events on errors +streaming/fdv2/fallback to FDv1 handling +polling/requests/URL path is computed correctly/no environment filter/base URI has a trailing slash/GET +polling/requests/URL path is computed correctly/environment_filter_key="encoding_not_necessary"/base URI has a trailing slash/GET +polling/requests/URL path is computed correctly/environment_filter_key="encoding necessary +! %& ( )"/base URI has no trailing slash/GET +polling/requests/URL path is computed correctly/environment_filter_key="encoding necessary +! %& ( )"/base URI has a trailing slash/GET From 3e465ab49b594a1d0170bb7e112e6ef37151c001 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Wed, 3 Jun 2026 18:12:29 -0700 Subject: [PATCH 3/5] ci: add v3 contract test harness job for FDv2 --- .github/workflows/server.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index f1b996dda..65758d400 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -36,6 +36,27 @@ jobs: test_service_port: ${{ env.TEST_SERVICE_PORT }} token: ${{ secrets.GITHUB_TOKEN }} + contract-tests-fdv2: + runs-on: ubuntu-22.04 + env: + TEST_SERVICE_PORT: 8123 + TEST_SERVICE_BINARY: ./build/contract-tests/server-contract-tests/server-tests + SUPPRESSION_FILE: contract-tests/server-contract-tests/test-suppressions-fdv2.txt + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 + - uses: ./.github/actions/ci + with: + cmake_target: server-tests + run_tests: false + - name: 'Launch test service as background task' + run: $TEST_SERVICE_BINARY $TEST_SERVICE_PORT 2>&1 & + - name: 'Run v3 SDK test harness' + run: | + curl -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v3.0.0-alpha.3/downloader/run.sh \ + | VERSION=v3.0.0-alpha.3 \ + PARAMS="-url http://localhost:$TEST_SERVICE_PORT -debug -stop-service-at-end -skip-from=$SUPPRESSION_FILE" \ + sh + contract-tests-curl: runs-on: ubuntu-22.04 env: From 607421582892903b36592d3a78b890bf12b46564 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Wed, 3 Jun 2026 18:15:37 -0700 Subject: [PATCH 4/5] ci: add curl-backend variant of v3 contract test harness job --- .github/workflows/server.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index 65758d400..00632e11f 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -80,6 +80,28 @@ jobs: test_service_port: ${{ env.TEST_SERVICE_PORT }} token: ${{ secrets.GITHUB_TOKEN }} + contract-tests-fdv2-curl: + runs-on: ubuntu-22.04 + env: + TEST_SERVICE_PORT: 8123 + TEST_SERVICE_BINARY: ./build/contract-tests/server-contract-tests/server-tests + SUPPRESSION_FILE: contract-tests/server-contract-tests/test-suppressions-fdv2.txt + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 + - uses: ./.github/actions/ci + with: + cmake_target: server-tests + run_tests: false + use_curl: true + - name: 'Launch test service as background task' + run: $TEST_SERVICE_BINARY $TEST_SERVICE_PORT 2>&1 & + - name: 'Run v3 SDK test harness' + run: | + curl -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v3.0.0-alpha.3/downloader/run.sh \ + | VERSION=v3.0.0-alpha.3 \ + PARAMS="-url http://localhost:$TEST_SERVICE_PORT -debug -stop-service-at-end -skip-from=$SUPPRESSION_FILE" \ + sh + build-test-server: runs-on: ubuntu-22.04 steps: From a6ffcae3fea878cbde1e84428f31de33ea11f377 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Wed, 3 Jun 2026 23:45:11 -0700 Subject: [PATCH 5/5] feat: derive FDv1 fallback synchronizer from harness synchronizers --- .../src/entity_manager.cpp | 41 +++++++++++++++++-- .../test-suppressions-fdv2.txt | 18 -------- 2 files changed, 37 insertions(+), 22 deletions(-) diff --git a/contract-tests/server-contract-tests/src/entity_manager.cpp b/contract-tests/server-contract-tests/src/entity_manager.cpp index 6cab18936..9943383a5 100644 --- a/contract-tests/server-contract-tests/src/entity_manager.cpp +++ b/contract-tests/server-contract-tests/src/entity_manager.cpp @@ -13,7 +13,8 @@ using namespace launchdarkly::server_side; namespace { config::builders::DataSystemBuilder::FDv2 BuildFDv2( - ConfigDataSystemParams const& cfg) { + ConfigDataSystemParams const& cfg, + config::builders::EndpointsBuilder* endpoints) { auto fdv2 = config::builders::DataSystemBuilder::FDv2(); if (cfg.synchronizers) { @@ -65,16 +66,47 @@ config::builders::DataSystemBuilder::FDv2 BuildFDv2( } } + using FDv2Builder = config::builders::DataSystemBuilder::FDv2; if (cfg.fdv1Fallback) { - auto p = decltype(fdv2)::Polling(); if (cfg.fdv1Fallback->baseUri) { - p.BaseUrl(*cfg.fdv1Fallback->baseUri); + endpoints->PollingBaseUrl(*cfg.fdv1Fallback->baseUri); } + FDv2Builder::FDv1Polling p; if (cfg.fdv1Fallback->pollIntervalMs) { p.PollInterval(std::chrono::duration_cast( std::chrono::milliseconds(*cfg.fdv1Fallback->pollIntervalMs))); } fdv2.FDv1Fallback(std::move(p)); + } else if (cfg.synchronizers && !cfg.synchronizers->empty()) { + // Derive an FDv1 fallback from the synchronizers list: prefer the + // first polling sync, otherwise reuse the first synchronizer's + // baseUri. The fallback is always polling. The fallback reads its + // URL from the global ServiceEndpoints, so set the polling endpoint + // to the selected baseUri. + ConfigDataSynchronizerParams const* selected = nullptr; + for (auto const& sync : *cfg.synchronizers) { + if (sync.polling) { + selected = &sync; + break; + } + } + if (!selected) { + selected = &cfg.synchronizers->front(); + } + FDv2Builder::FDv1Polling p; + if (selected->polling) { + if (selected->polling->baseUri) { + endpoints->PollingBaseUrl(*selected->polling->baseUri); + } + if (selected->polling->pollIntervalMs) { + p.PollInterval(std::chrono::duration_cast( + std::chrono::milliseconds( + *selected->polling->pollIntervalMs))); + } + } else if (selected->streaming && selected->streaming->baseUri) { + endpoints->PollingBaseUrl(*selected->streaming->baseUri); + } + fdv2.FDv1Fallback(std::move(p)); } return fdv2; @@ -163,7 +195,8 @@ std::optional EntityManager::create(ConfigParams const& in) { } if (in.dataSystem) { - config_builder.DataSystem().Method(BuildFDv2(*in.dataSystem)); + config_builder.DataSystem().Method( + BuildFDv2(*in.dataSystem, &endpoints)); } else { config_builder.DataSystem().Method(BuildBackgroundSync(in, &endpoints)); } diff --git a/contract-tests/server-contract-tests/test-suppressions-fdv2.txt b/contract-tests/server-contract-tests/test-suppressions-fdv2.txt index 7db130738..e69de29bb 100644 --- a/contract-tests/server-contract-tests/test-suppressions-fdv2.txt +++ b/contract-tests/server-contract-tests/test-suppressions-fdv2.txt @@ -1,18 +0,0 @@ -events/summary events/flag versions -streaming/requests/URL path is computed correctly/environment_filter_key="encoding necessary +! %& ( )"/base URI has no trailing slash/GET -streaming/requests/URL path is computed correctly/environment_filter_key="encoding necessary +! %& ( )"/base URI has a trailing slash/GET -streaming/validation/drop and reconnect if stream event has malformed JSON/server-intent event -streaming/validation/drop and reconnect if stream event has well-formed JSON not matching schema/server-intent event -streaming/validation/unrecognized data that can be safely ignored/unknown event name with JSON body -streaming/validation/unrecognized data that can be safely ignored/unknown event name with non-JSON body -streaming/validation/unrecognized data that can be safely ignored/patch event with unrecognized path kind -streaming/fdv2/updates are not complete until payload transferred is sent -streaming/fdv2/handles multiple updates -streaming/fdv2/ignores model version -streaming/fdv2/ignores heart beat -streaming/fdv2/can discard partial events on errors -streaming/fdv2/fallback to FDv1 handling -polling/requests/URL path is computed correctly/no environment filter/base URI has a trailing slash/GET -polling/requests/URL path is computed correctly/environment_filter_key="encoding_not_necessary"/base URI has a trailing slash/GET -polling/requests/URL path is computed correctly/environment_filter_key="encoding necessary +! %& ( )"/base URI has no trailing slash/GET -polling/requests/URL path is computed correctly/environment_filter_key="encoding necessary +! %& ( )"/base URI has a trailing slash/GET