From 2edfc8d93e44e9851438e6c13cb356ea50ea13ec Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 3 Nov 2025 12:53:34 -0800 Subject: [PATCH 01/10] chore: Add support for persistent store contract tests. --- .github/actions/ci/action.yml | 8 ++- .github/workflows/server.yml | 4 ++ .../include/data_model/data_model.hpp | 32 ++++++++++- .../server-contract-tests/CMakeLists.txt | 5 ++ .../src/entity_manager.cpp | 54 +++++++++++++++++++ .../server-contract-tests/src/main.cpp | 3 ++ scripts/build.sh | 8 +++ 7 files changed, 111 insertions(+), 3 deletions(-) diff --git a/.github/actions/ci/action.yml b/.github/actions/ci/action.yml index 35c1ca23b..a68d659de 100644 --- a/.github/actions/ci/action.yml +++ b/.github/actions/ci/action.yml @@ -37,6 +37,10 @@ inputs: description: 'Whether to install CURL development libraries. Required for OpenTelemetry builds (server-sdk-otel), but does not enable CURL networking for the SDK itself.' required: false default: 'false' + use_redis: + description: 'Whether to enable Redis support (LD_BUILD_REDIS_SUPPORT=ON)' + required: false + default: 'false' runs: using: composite @@ -58,7 +62,7 @@ runs: id: install-curl - name: Build Library shell: bash - run: ./scripts/build.sh ${{ inputs.cmake_target }} ON ${{ inputs.use_curl }} + run: ./scripts/build.sh ${{ inputs.cmake_target }} ON ${{ inputs.use_curl }} ${{ inputs.use_redis }} env: BOOST_ROOT: ${{ steps.install-boost.outputs.BOOST_ROOT }} Boost_DIR: ${{ steps.install-boost.outputs.Boost_DIR }} @@ -69,7 +73,7 @@ runs: id: build-tests if: inputs.run_tests == 'true' shell: bash - run: ./scripts/build.sh gtest_${{ inputs.cmake_target }} ON ${{ inputs.use_curl }} + run: ./scripts/build.sh gtest_${{ inputs.cmake_target }} ON ${{ inputs.use_curl }} ${{ inputs.use_redis }} env: BOOST_ROOT: ${{ steps.install-boost.outputs.BOOST_ROOT }} Boost_DIR: ${{ steps.install-boost.outputs.Boost_DIR }} diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index 3542c8b50..88e879eab 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -26,6 +26,7 @@ jobs: with: cmake_target: server-tests run_tests: false + use_redis: true - name: 'Launch test service as background task' run: $TEST_SERVICE_BINARY $TEST_SERVICE_PORT 2>&1 & - uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1.1.0 @@ -33,6 +34,7 @@ jobs: # Inform the test harness of test service's port. test_service_port: ${{ env.TEST_SERVICE_PORT }} token: ${{ secrets.GITHUB_TOKEN }} + enable_persistence_tests: true contract-tests-curl: runs-on: ubuntu-22.04 @@ -47,6 +49,7 @@ jobs: cmake_target: server-tests run_tests: false use_curl: true + use_redis: true - name: 'Launch test service as background task' run: $TEST_SERVICE_BINARY $TEST_SERVICE_PORT 2>&1 & - uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1.1.0 @@ -54,6 +57,7 @@ jobs: # Inform the test harness of test service's port. test_service_port: ${{ env.TEST_SERVICE_PORT }} token: ${{ secrets.GITHUB_TOKEN }} + enable_persistence_tests: true build-test-server: runs-on: ubuntu-22.04 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..ef3aab779 100644 --- a/contract-tests/data-model/include/data_model/data_model.hpp +++ b/contract-tests/data-model/include/data_model/data_model.hpp @@ -150,6 +150,34 @@ struct ConfigWrapper { NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigWrapper, name, version); +struct ConfigPersistentCache { + std::string mode; // "off", "ttl", "infinite" + std::optional ttlMs; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigPersistentCache, + mode, + ttlMs); + +struct ConfigPersistentStore { + std::string type; // "redis", "consul", "dynamodb" + std::string dsn; + std::optional prefix; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigPersistentStore, + type, + dsn, + prefix); + +struct ConfigPersistentDataStore { + ConfigPersistentStore store; + ConfigPersistentCache cache; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigPersistentDataStore, + store, + cache); struct ConfigParams { std::string credential; std::optional startWaitTimeMs; @@ -164,6 +192,7 @@ struct ConfigParams { std::optional proxy; std::optional hooks; std::optional wrapper; + std::optional persistentDataStore; }; NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigParams, @@ -179,7 +208,8 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigParams, tls, proxy, hooks, - wrapper); + wrapper, + persistentDataStore); struct ContextSingleParams { std::optional kind; diff --git a/contract-tests/server-contract-tests/CMakeLists.txt b/contract-tests/server-contract-tests/CMakeLists.txt index 9455cd27e..faeca51eb 100644 --- a/contract-tests/server-contract-tests/CMakeLists.txt +++ b/contract-tests/server-contract-tests/CMakeLists.txt @@ -28,4 +28,9 @@ target_link_libraries(server-tests PRIVATE contract-test-data-model ) +if (LD_BUILD_REDIS_SUPPORT) + target_link_libraries(server-tests PRIVATE launchdarkly::server_redis_source) + target_compile_definitions(server-tests PRIVATE LD_REDIS_SUPPORT_ENABLED) +endif () + target_include_directories(server-tests PUBLIC include) diff --git a/contract-tests/server-contract-tests/src/entity_manager.cpp b/contract-tests/server-contract-tests/src/entity_manager.cpp index 085df1c2d..b337d5598 100644 --- a/contract-tests/server-contract-tests/src/entity_manager.cpp +++ b/contract-tests/server-contract-tests/src/entity_manager.cpp @@ -7,6 +7,10 @@ #include +#ifdef LD_REDIS_SUPPORT_ENABLED +#include +#endif + using launchdarkly::LogLevel; using namespace launchdarkly::server_side; @@ -154,6 +158,56 @@ std::optional EntityManager::create(ConfigParams const& in) { } } +#ifdef LD_REDIS_SUPPORT_ENABLED + if (in.persistentDataStore) { + if (in.persistentDataStore->store.type == "redis") { + std::string prefix = + in.persistentDataStore->store.prefix.value_or("launchdarkly"); + + auto redis_result = launchdarkly::server_side::integrations:: + RedisDataSource::Create(in.persistentDataStore->store.dsn, + prefix); + + if (!redis_result) { + LD_LOG(logger_, LogLevel::kWarn) + << "entity_manager: couldn't create Redis data source: " + << redis_result.error(); + return std::nullopt; + } + + auto lazy_load = config::builders::LazyLoadBuilder(); + lazy_load.Source(std::move(*redis_result)); + + // Configure cache mode + // Default is 5 minutes, but contract tests may specify: + // - "off": disable caching (fetch from DB every time) + // - "ttl": custom TTL in milliseconds + // - "infinite": never expire cached items + if (in.persistentDataStore->cache.mode == "off") { + lazy_load.CacheRefresh(std::chrono::seconds(0)); + } else if (in.persistentDataStore->cache.mode == "ttl") { + if (in.persistentDataStore->cache.ttlMs) { + lazy_load.CacheRefresh(std::chrono::milliseconds( + *in.persistentDataStore->cache.ttlMs)); + } + } else if (in.persistentDataStore->cache.mode == "infinite") { + // Use a very large TTL to effectively never expire + lazy_load.CacheRefresh(std::chrono::hours(24 * 365)); + } + // If no mode specified, the default 5-minute TTL is used + + config_builder.DataSystem().Method( + config::builders::DataSystemBuilder::LazyLoad( + std::move(lazy_load))); + } else { + LD_LOG(logger_, LogLevel::kWarn) + << "entity_manager: unsupported persistent store type: " + << in.persistentDataStore->store.type; + return std::nullopt; + } + } +#endif + auto config = config_builder.Build(); if (!config) { LD_LOG(logger_, LogLevel::kWarn) diff --git a/contract-tests/server-contract-tests/src/main.cpp b/contract-tests/server-contract-tests/src/main.cpp index e017a8634..481997961 100644 --- a/contract-tests/server-contract-tests/src/main.cpp +++ b/contract-tests/server-contract-tests/src/main.cpp @@ -51,6 +51,9 @@ int main(int argc, char* argv[]) { srv.add_capability("evaluation-hooks"); srv.add_capability("track-hooks"); srv.add_capability("wrapper"); +#ifdef LD_REDIS_SUPPORT_ENABLED + srv.add_capability("persistent-data-store-redis"); +#endif net::signal_set signals{ioc, SIGINT, SIGTERM}; diff --git a/scripts/build.sh b/scripts/build.sh index 9ca6d21a2..1be4f9bc5 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -7,6 +7,7 @@ # $1 the name of the target. For example "launchdarkly-cpp-common". # $2 ON/OFF which enables/disables building in a test configuration (unit tests + contract tests.) # $3 (optional) true/false to enable/disable CURL networking (LD_CURL_NETWORKING) +# $4 (optional) true/false to enable/disable Redis support (LD_BUILD_REDIS_SUPPORT) function cleanup { cd .. @@ -30,6 +31,13 @@ build_curl="OFF" if [ "$3" == "true" ]; then build_curl="ON" fi + +# Check for Redis support option (override the automatic detection if explicitly passed) +if [ "$4" == "true" ]; then + build_redis="ON" +elif [ "$4" == "false" ]; then + build_redis="OFF" +fi # Special case: OpenTelemetry support requires additional dependencies. # Enable OTEL support and fetch deps when building OTEL targets. build_otel="OFF" From eb918dda13a3ddb8de4bd31730447bcb4947c277 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:31:39 +0000 Subject: [PATCH 02/10] fix: add use_redis flag to server-redis.yml workflow steps Co-Authored-By: rlamb@launchdarkly.com --- .github/workflows/server-redis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/server-redis.yml b/.github/workflows/server-redis.yml index 82f31ca4b..255c9c14b 100644 --- a/.github/workflows/server-redis.yml +++ b/.github/workflows/server-redis.yml @@ -28,6 +28,7 @@ jobs: with: cmake_target: launchdarkly-cpp-server-redis-source simulate_release: true + use_redis: true build-redis-mac: runs-on: macos-13 steps: @@ -39,6 +40,7 @@ jobs: platform_version: 12 run_tests: false # TODO: figure out how to run Redis service on Mac simulate_release: true + use_redis: true build-test-redis-windows: runs-on: windows-2022 steps: @@ -57,3 +59,4 @@ jobs: toolset: msvc run_tests: false # TODO: figure out how to run Redis service on Windows simulate_windows_release: true + use_redis: true From d960e6f748298da3844cc61dc13b92d0607e39bf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:08:36 +0000 Subject: [PATCH 03/10] fix: update OpenSSL chocolatey version from 3.5.4 to 3.6.1 for Windows CI Co-Authored-By: rlamb@launchdarkly.com --- .github/actions/install-openssl/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/install-openssl/action.yml b/.github/actions/install-openssl/action.yml index 6d10d813f..3674a8b0d 100644 --- a/.github/actions/install-openssl/action.yml +++ b/.github/actions/install-openssl/action.yml @@ -32,7 +32,7 @@ runs: if: runner.os == 'Windows' shell: bash run: | - choco install openssl --version 3.5.4 -y --no-progress + choco install openssl --version 3.6.1 -y --no-progress if [ -d "C:\Program Files\OpenSSL-Win64" ]; then echo "OPENSSL_ROOT_DIR=C:\Program Files\OpenSSL-Win64" >> $GITHUB_OUTPUT else From 14b2e22918e297f0860dbfbb65141edf9ffed212 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:48:07 +0000 Subject: [PATCH 04/10] Revert "fix: update OpenSSL chocolatey version from 3.5.4 to 3.6.1 for Windows CI" This reverts commit d960e6f748298da3844cc61dc13b92d0607e39bf. --- .github/actions/install-openssl/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/install-openssl/action.yml b/.github/actions/install-openssl/action.yml index 3674a8b0d..6d10d813f 100644 --- a/.github/actions/install-openssl/action.yml +++ b/.github/actions/install-openssl/action.yml @@ -32,7 +32,7 @@ runs: if: runner.os == 'Windows' shell: bash run: | - choco install openssl --version 3.6.1 -y --no-progress + choco install openssl --version 3.5.4 -y --no-progress if [ -d "C:\Program Files\OpenSSL-Win64" ]; then echo "OPENSSL_ROOT_DIR=C:\Program Files\OpenSSL-Win64" >> $GITHUB_OUTPUT else From b9a13e42a3c7508efd3a943bc272ddd6f4fe42dd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:06:04 +0000 Subject: [PATCH 05/10] fix: move enable_persistence_tests to Redis contract test jobs Removed enable_persistence_tests and use_redis from server.yml contract tests (which have no Redis service). Added dedicated contract-tests and contract-tests-curl jobs to server-redis.yml with a Redis service container and enable_persistence_tests: true. Co-Authored-By: rlamb@launchdarkly.com --- .github/workflows/server-redis.yml | 55 ++++++++++++++++++++++++++++++ .github/workflows/server.yml | 4 --- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/.github/workflows/server-redis.yml b/.github/workflows/server-redis.yml index 74f3c50a6..7ff8bf84c 100644 --- a/.github/workflows/server-redis.yml +++ b/.github/workflows/server-redis.yml @@ -14,6 +14,61 @@ on: - cron: '0 8 * * *' jobs: + contract-tests: + runs-on: ubuntu-22.04 + services: + redis: + image: redis + ports: + - 6379:6379 + env: + TEST_SERVICE_PORT: 8123 + TEST_SERVICE_BINARY: ./build/contract-tests/server-contract-tests/server-tests + steps: + # https://github.com/actions/checkout/releases/tag/v4.3.0 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 + - uses: ./.github/actions/ci + with: + cmake_target: server-tests + run_tests: false + use_redis: true + - name: 'Launch test service as background task' + run: $TEST_SERVICE_BINARY $TEST_SERVICE_PORT 2>&1 & + # https://github.com/launchdarkly/gh-actions/releases/tag/contract-tests-v1.1.0 + - uses: launchdarkly/gh-actions/actions/contract-tests@2715574e04448246bc529a23a81766491bbc4aae + with: + test_service_port: ${{ env.TEST_SERVICE_PORT }} + token: ${{ secrets.GITHUB_TOKEN }} + enable_persistence_tests: true + + contract-tests-curl: + runs-on: ubuntu-22.04 + services: + redis: + image: redis + ports: + - 6379:6379 + env: + TEST_SERVICE_PORT: 8123 + TEST_SERVICE_BINARY: ./build/contract-tests/server-contract-tests/server-tests + steps: + # https://github.com/actions/checkout/releases/tag/v4.3.0 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 + - uses: ./.github/actions/ci + with: + cmake_target: server-tests + run_tests: false + use_curl: true + use_redis: true + - name: 'Launch test service as background task' + run: $TEST_SERVICE_BINARY $TEST_SERVICE_PORT 2>&1 & + # https://github.com/launchdarkly/gh-actions/releases/tag/contract-tests-v1.1.0 + - uses: launchdarkly/gh-actions/actions/contract-tests@2715574e04448246bc529a23a81766491bbc4aae + with: + test_service_port: ${{ env.TEST_SERVICE_PORT }} + token: ${{ secrets.GITHUB_TOKEN }} + enable_persistence_tests: true + build-test-redis: runs-on: ubuntu-22.04 services: diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index 5f836700b..f1b996dda 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -27,7 +27,6 @@ jobs: with: cmake_target: server-tests run_tests: false - use_redis: true - name: 'Launch test service as background task' run: $TEST_SERVICE_BINARY $TEST_SERVICE_PORT 2>&1 & # https://github.com/launchdarkly/gh-actions/releases/tag/contract-tests-v1.1.0 @@ -36,7 +35,6 @@ jobs: # Inform the test harness of test service's port. test_service_port: ${{ env.TEST_SERVICE_PORT }} token: ${{ secrets.GITHUB_TOKEN }} - enable_persistence_tests: true contract-tests-curl: runs-on: ubuntu-22.04 @@ -52,7 +50,6 @@ jobs: cmake_target: server-tests run_tests: false use_curl: true - use_redis: true - name: 'Launch test service as background task' run: $TEST_SERVICE_BINARY $TEST_SERVICE_PORT 2>&1 & # https://github.com/launchdarkly/gh-actions/releases/tag/contract-tests-v1.1.0 @@ -61,7 +58,6 @@ jobs: # Inform the test harness of test service's port. test_service_port: ${{ env.TEST_SERVICE_PORT }} token: ${{ secrets.GITHUB_TOKEN }} - enable_persistence_tests: true build-test-server: runs-on: ubuntu-22.04 From 9a95f3ee299e9b13789e7fdf5787b26c7b2ab205 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:27:28 +0000 Subject: [PATCH 06/10] fix: change use_redis default to empty string to preserve auto-detection The default of 'false' was always passed as $4 to build.sh, which explicitly set build_redis='OFF' and overrode the auto-detection for Redis target names. An empty default means $4 is empty, so neither branch matches and auto-detection is preserved. Co-Authored-By: rlamb@launchdarkly.com --- .github/actions/ci/action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/ci/action.yml b/.github/actions/ci/action.yml index a68d659de..a1e30de49 100644 --- a/.github/actions/ci/action.yml +++ b/.github/actions/ci/action.yml @@ -38,9 +38,9 @@ inputs: required: false default: 'false' use_redis: - description: 'Whether to enable Redis support (LD_BUILD_REDIS_SUPPORT=ON)' + description: 'Whether to enable Redis support (LD_BUILD_REDIS_SUPPORT=ON). Leave unset to preserve automatic detection for Redis targets.' required: false - default: 'false' + default: '' runs: using: composite From bdcb385e5feb39bbc89a3b894cb0aaddd9ae2d88 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:47:57 +0000 Subject: [PATCH 07/10] fix: update contract-tests action to v1.3.0 for enable_persistence_tests support The pinned SHA (contract-tests-v1.1.0) did not have the enable_persistence_tests input, causing persistence tests to be skipped. Updated to contract-tests-v1.3.0 which supports this input. Co-Authored-By: rlamb@launchdarkly.com --- .github/workflows/server-redis.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/server-redis.yml b/.github/workflows/server-redis.yml index 7ff8bf84c..0a88a059b 100644 --- a/.github/workflows/server-redis.yml +++ b/.github/workflows/server-redis.yml @@ -34,8 +34,8 @@ jobs: use_redis: true - name: 'Launch test service as background task' run: $TEST_SERVICE_BINARY $TEST_SERVICE_PORT 2>&1 & - # https://github.com/launchdarkly/gh-actions/releases/tag/contract-tests-v1.1.0 - - uses: launchdarkly/gh-actions/actions/contract-tests@2715574e04448246bc529a23a81766491bbc4aae + # https://github.com/launchdarkly/gh-actions/releases/tag/contract-tests-v1.3.0 + - uses: launchdarkly/gh-actions/actions/contract-tests@5adb11fd6953e1bc35d9cf1fc1b4374c464e3a8b with: test_service_port: ${{ env.TEST_SERVICE_PORT }} token: ${{ secrets.GITHUB_TOKEN }} @@ -62,8 +62,8 @@ jobs: use_redis: true - name: 'Launch test service as background task' run: $TEST_SERVICE_BINARY $TEST_SERVICE_PORT 2>&1 & - # https://github.com/launchdarkly/gh-actions/releases/tag/contract-tests-v1.1.0 - - uses: launchdarkly/gh-actions/actions/contract-tests@2715574e04448246bc529a23a81766491bbc4aae + # https://github.com/launchdarkly/gh-actions/releases/tag/contract-tests-v1.3.0 + - uses: launchdarkly/gh-actions/actions/contract-tests@5adb11fd6953e1bc35d9cf1fc1b4374c464e3a8b with: test_service_port: ${{ env.TEST_SERVICE_PORT }} token: ${{ secrets.GITHUB_TOKEN }} From 8d4e39c6262457108c84c4020b508c7027f9794f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:50:28 +0000 Subject: [PATCH 08/10] fix: correct TTL field name mismatch in persistent cache config The Go test harness sends cache TTL as 'ttl' (in seconds), but the C++ data model expected 'ttlMs' (in milliseconds). This meant the TTL value was never deserialized, and the default 5-minute cache TTL was always used regardless of what the test requested. - Rename ConfigPersistentCache::ttlMs to ttl to match JSON field name - Change CacheRefresh from milliseconds to seconds to match harness units Co-Authored-By: rlamb@launchdarkly.com --- .../data-model/include/data_model/data_model.hpp | 4 ++-- .../server-contract-tests/src/entity_manager.cpp | 8 ++++---- 2 files changed, 6 insertions(+), 6 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 ef3aab779..d05d8c73f 100644 --- a/contract-tests/data-model/include/data_model/data_model.hpp +++ b/contract-tests/data-model/include/data_model/data_model.hpp @@ -152,12 +152,12 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigWrapper, name, version); struct ConfigPersistentCache { std::string mode; // "off", "ttl", "infinite" - std::optional ttlMs; + std::optional ttl; // TTL in seconds (sent by test harness as "ttl") }; NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigPersistentCache, mode, - ttlMs); + ttl); struct ConfigPersistentStore { std::string type; // "redis", "consul", "dynamodb" diff --git a/contract-tests/server-contract-tests/src/entity_manager.cpp b/contract-tests/server-contract-tests/src/entity_manager.cpp index b337d5598..63e095481 100644 --- a/contract-tests/server-contract-tests/src/entity_manager.cpp +++ b/contract-tests/server-contract-tests/src/entity_manager.cpp @@ -181,14 +181,14 @@ std::optional EntityManager::create(ConfigParams const& in) { // Configure cache mode // Default is 5 minutes, but contract tests may specify: // - "off": disable caching (fetch from DB every time) - // - "ttl": custom TTL in milliseconds + // - "ttl": custom TTL in seconds (test harness sends seconds) // - "infinite": never expire cached items if (in.persistentDataStore->cache.mode == "off") { lazy_load.CacheRefresh(std::chrono::seconds(0)); } else if (in.persistentDataStore->cache.mode == "ttl") { - if (in.persistentDataStore->cache.ttlMs) { - lazy_load.CacheRefresh(std::chrono::milliseconds( - *in.persistentDataStore->cache.ttlMs)); + if (in.persistentDataStore->cache.ttl) { + lazy_load.CacheRefresh(std::chrono::seconds( + *in.persistentDataStore->cache.ttl)); } } else if (in.persistentDataStore->cache.mode == "infinite") { // Use a very large TTL to effectively never expire From e5bf095dee7f6971ba839641bf422d710a712963 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:56:07 +0000 Subject: [PATCH 09/10] fix: allow lazy load evaluations when $inited key is not set In lazy load / daemon mode, the SDK's Initialized() check was blocking all flag evaluations when the $inited key was not found in the persistent store. This is problematic because in daemon mode, an external process (like Relay Proxy) populates the store, and the $inited key may not always be present. The fix changes LazyLoad::Initialized() to always return true, allowing evaluations to proceed using available data. When the underlying source reports not initialized ($inited key not found), a warning is logged to alert operators that a Relay Proxy or other SDK should set this key. This aligns with the Go SDK behavior where daemon mode (ExternalUpdatesOnly) always considers the data source initialized. Updated unit tests to reflect the new behavior and added tests verifying the warning is logged appropriately. Co-Authored-By: rlamb@launchdarkly.com --- .../lazy_load/lazy_load_system.cpp | 37 ++++++++++------ .../lazy_load/lazy_load_system.hpp | 1 + .../tests/lazy_load_system_test.cpp | 42 +++++++++++++++++-- 3 files changed, 65 insertions(+), 15 deletions(-) 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 4235b9676..196ee0747 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 @@ -79,9 +79,12 @@ std::string const& LazyLoad::Identity() const { void LazyLoad::Initialize() { status_manager_.SetState(DataSourceState::kInitializing); - if (Initialized()) { - status_manager_.SetState(DataSourceState::kValid); - } + // In lazy load mode, we always consider the system ready for + // evaluations. The Initialized() call here will log a warning if + // the underlying source reports not initialized (e.g. $inited key + // not found), but we proceed regardless. + Initialized(); + status_manager_.SetState(DataSourceState::kValid); } std::shared_ptr LazyLoad::GetFlag( @@ -121,25 +124,35 @@ LazyLoad::AllSegments() const { } bool LazyLoad::Initialized() const { - /* Since the memory store isn't provisioned with an initial SDKDataSet - * like in the Background Sync system, we can't forward this call to - * MemoryStore::Initialized(). Instead, we need to check the state of the - * underlying source. */ + /* In lazy load mode, the system is always considered initialized for + * the purpose of flag evaluations. Data is fetched on-demand from the + * underlying source regardless of the $inited key state. + * + * However, we still check the underlying source's initialized state + * so we can warn if $inited is not set. A properly configured + * Relay Proxy or other SDK populating the store should set the + * $inited key. */ auto const state = tracker_.State(Keys::kInitialized, time_()); if (initialized_.has_value()) { - /* Once initialized, we can always return true. */ if (initialized_.value()) { return true; } - /* If not yet initialized, then we can return false only if the state is - * fresh - otherwise we should make an attempt to refresh. */ if (data_components::ExpirationTracker::TrackState::kFresh == state) { - return false; + return true; } } RefreshInitState(); - return initialized_.value_or(false); + if (!initialized_.value_or(false) && !logged_init_warning_) { + LD_LOG(logger_, LogLevel::kWarn) + << "LazyLoad: data source reports not initialized " + "(the $inited key was not found in the store). " + "Evaluations will proceed using available data. " + "Typically a Relay Proxy or other SDK should set this " + "key; verify your configuration if this is unexpected."; + logged_init_warning_ = true; + } + return true; } void LazyLoad::RefreshAllFlags() const { 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 9584f60d6..960a641b1 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 @@ -187,6 +187,7 @@ class LazyLoad final : public data_interfaces::IDataSystem { mutable data_components::ExpirationTracker tracker_; TimeFn time_; mutable std::optional initialized_; + mutable bool logged_init_warning_{false}; ClockType::duration fresh_duration_; diff --git a/libs/server-sdk/tests/lazy_load_system_test.cpp b/libs/server-sdk/tests/lazy_load_system_test.cpp index f908a8ac8..b32723691 100644 --- a/libs/server-sdk/tests/lazy_load_system_test.cpp +++ b/libs/server-sdk/tests/lazy_load_system_test.cpp @@ -283,7 +283,7 @@ TEST_F(LazyLoadTest, AllSegmentsRefreshesIndividualSegment) { ASSERT_EQ(segment2->version, 2); } -TEST_F(LazyLoadTest, InitializeNotQueriedRepeatedly) { +TEST_F(LazyLoadTest, InitializedReturnsTrueEvenWhenSourceNotInitialized) { built::LazyLoadConfig const config{ built::LazyLoadConfig::EvictionPolicy::Disabled, std::chrono::seconds(10), mock_reader}; @@ -292,8 +292,10 @@ TEST_F(LazyLoadTest, InitializeNotQueriedRepeatedly) { data_systems::LazyLoad const lazy_load(logger, config, status_manager); + // In lazy load mode, Initialized() always returns true even when the + // underlying source reports not initialized ($inited key not found). for (std::size_t i = 0; i < 10; i++) { - ASSERT_FALSE(lazy_load.Initialized()); + ASSERT_TRUE(lazy_load.Initialized()); } } @@ -329,12 +331,46 @@ TEST_F(LazyLoadTest, InitializeCalledAgainAfterTTL) { data_systems::LazyLoad const lazy_load(logger, config, status_manager, [&]() { return now; }); + // Always returns true even when source reports not initialized. for (std::size_t i = 0; i < 10; i++) { - ASSERT_FALSE(lazy_load.Initialized()); + ASSERT_TRUE(lazy_load.Initialized()); now += std::chrono::seconds(1); } + // Still true after TTL when source now reports initialized. for (std::size_t i = 0; i < 10; i++) { ASSERT_TRUE(lazy_load.Initialized()); } } + +TEST_F(LazyLoadTest, InitializedLogsWarningWhenSourceNotInitialized) { + built::LazyLoadConfig const config{ + built::LazyLoadConfig::EvictionPolicy::Disabled, + std::chrono::seconds(10), mock_reader}; + + EXPECT_CALL(*mock_reader, Initialized).WillOnce(Return(false)); + + data_systems::LazyLoad const lazy_load(logger, config, status_manager); + + ASSERT_TRUE(lazy_load.Initialized()); + + // A warning should have been logged about $inited not being found. + ASSERT_TRUE(spy_logger_backend->Contains( + 0, LogLevel::kWarn, "$inited")); +} + +TEST_F(LazyLoadTest, InitializedDoesNotLogWarningWhenSourceIsInitialized) { + built::LazyLoadConfig const config{ + built::LazyLoadConfig::EvictionPolicy::Disabled, + std::chrono::seconds(10), mock_reader}; + + EXPECT_CALL(*mock_reader, Initialized).WillOnce(Return(true)); + + data_systems::LazyLoad const lazy_load(logger, config, status_manager); + + ASSERT_TRUE(lazy_load.Initialized()); + + // No warning should be logged when source is properly initialized. + // Only debug-level messages should be present (or none at all). + ASSERT_TRUE(spy_logger_backend->Count(0)); +} From cc44864970c1baaadf00bcacb0a429b40e364017 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:23:26 +0000 Subject: [PATCH 10/10] fix: move warn-and-proceed logic to evaluation path for lazy load Reworked approach based on review feedback: Initialized() should return false when $inited is not set (consistent with other SDK implementations), and the evaluation path should handle this case by warning and proceeding rather than blocking. Changes: - Added CanEvaluateWhenNotInitialized() virtual method to IDataSystem interface (defaults to false) - LazyLoad overrides to return true (can serve on demand) - PreEvaluationChecks warns and proceeds when data system can evaluate while not initialized, instead of returning CLIENT_NOT_READY - AllFlagsState similarly warns and proceeds instead of returning empty - Reverted LazyLoad::Initialized() to original behavior (truthfully reports whether $inited key exists) - Added unit test for CanEvaluateWhenNotInitialized() This matches the pattern used in the Erlang SDK where the evaluation path distinguishes between 'not initialized' (blocks) and 'store initialized' (warns but proceeds). Co-Authored-By: rlamb@launchdarkly.com --- libs/server-sdk/src/client_impl.cpp | 27 ++++++++++--- .../data_interfaces/system/idata_system.hpp | 16 ++++++++ .../lazy_load/lazy_load_system.cpp | 37 ++++++------------ .../lazy_load/lazy_load_system.hpp | 5 ++- .../tests/lazy_load_system_test.cpp | 38 ++++--------------- 5 files changed, 61 insertions(+), 62 deletions(-) diff --git a/libs/server-sdk/src/client_impl.cpp b/libs/server-sdk/src/client_impl.cpp index 0c53440d7..64d9f17b3 100644 --- a/libs/server-sdk/src/client_impl.cpp +++ b/libs/server-sdk/src/client_impl.cpp @@ -186,11 +186,19 @@ AllFlagsState ClientImpl::AllFlagsState(Context const& context, std::unordered_map result; if (!Initialized()) { - LD_LOG(logger_, LogLevel::kWarn) - << "AllFlagsState() called before client has finished " - "initializing. Data source not available. Returning empty state"; + if (data_system_->CanEvaluateWhenNotInitialized()) { + LD_LOG(logger_, LogLevel::kWarn) + << "AllFlagsState() called before LaunchDarkly client " + "initialization completed; using last known values " + "from data store"; + } else { + LD_LOG(logger_, LogLevel::kWarn) + << "AllFlagsState() called before client has finished " + "initializing. Data source not available. Returning " + "empty state"; - return {}; + return {}; + } } AllFlagsStateBuilder builder{options}; @@ -418,7 +426,16 @@ EvaluationDetail ClientImpl::VariationInternal( std::optional ClientImpl::PreEvaluationChecks( Context const& context) const { if (!Initialized()) { - return EvaluationReason::ErrorKind::kClientNotReady; + if (data_system_->CanEvaluateWhenNotInitialized()) { + LD_LOG(logger_, LogLevel::kWarn) + << "Evaluation called before LaunchDarkly client " + "initialization completed; using last known values " + "from data store. The $inited key was not found in " + "the store; typically a Relay Proxy or other SDK " + "should set this key."; + } else { + return EvaluationReason::ErrorKind::kClientNotReady; + } } if (!context.Valid()) { return EvaluationReason::ErrorKind::kUserNotSpecified; 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..73b1eefef 100644 --- a/libs/server-sdk/src/data_interfaces/system/idata_system.hpp +++ b/libs/server-sdk/src/data_interfaces/system/idata_system.hpp @@ -21,6 +21,22 @@ class IDataSystem : public IStore { */ virtual void Initialize() = 0; + /** + * @brief Returns true if the data system is capable of serving + * flag evaluations even when Initialized() returns false. + * + * This is the case for Lazy Load (daemon mode), where data can be + * fetched on-demand from the persistent store regardless of whether + * the $inited key has been set. In contrast, Background Sync + * cannot serve evaluations until initial data is received. + * + * When this returns true, the evaluation path should log a warning + * (rather than returning CLIENT_NOT_READY) if Initialized() is false. + */ + [[nodiscard]] virtual bool CanEvaluateWhenNotInitialized() const { + return false; + } + virtual ~IDataSystem() override = default; IDataSystem(IDataSystem const& item) = delete; IDataSystem(IDataSystem&& item) = delete; 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 196ee0747..4235b9676 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 @@ -79,12 +79,9 @@ std::string const& LazyLoad::Identity() const { void LazyLoad::Initialize() { status_manager_.SetState(DataSourceState::kInitializing); - // In lazy load mode, we always consider the system ready for - // evaluations. The Initialized() call here will log a warning if - // the underlying source reports not initialized (e.g. $inited key - // not found), but we proceed regardless. - Initialized(); - status_manager_.SetState(DataSourceState::kValid); + if (Initialized()) { + status_manager_.SetState(DataSourceState::kValid); + } } std::shared_ptr LazyLoad::GetFlag( @@ -124,35 +121,25 @@ LazyLoad::AllSegments() const { } bool LazyLoad::Initialized() const { - /* In lazy load mode, the system is always considered initialized for - * the purpose of flag evaluations. Data is fetched on-demand from the - * underlying source regardless of the $inited key state. - * - * However, we still check the underlying source's initialized state - * so we can warn if $inited is not set. A properly configured - * Relay Proxy or other SDK populating the store should set the - * $inited key. */ + /* Since the memory store isn't provisioned with an initial SDKDataSet + * like in the Background Sync system, we can't forward this call to + * MemoryStore::Initialized(). Instead, we need to check the state of the + * underlying source. */ auto const state = tracker_.State(Keys::kInitialized, time_()); if (initialized_.has_value()) { + /* Once initialized, we can always return true. */ if (initialized_.value()) { return true; } + /* If not yet initialized, then we can return false only if the state is + * fresh - otherwise we should make an attempt to refresh. */ if (data_components::ExpirationTracker::TrackState::kFresh == state) { - return true; + return false; } } RefreshInitState(); - if (!initialized_.value_or(false) && !logged_init_warning_) { - LD_LOG(logger_, LogLevel::kWarn) - << "LazyLoad: data source reports not initialized " - "(the $inited key was not found in the store). " - "Evaluations will proceed using available data. " - "Typically a Relay Proxy or other SDK should set this " - "key; verify your configuration if this is unexpected."; - logged_init_warning_ = true; - } - return true; + return initialized_.value_or(false); } void LazyLoad::RefreshAllFlags() const { 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 960a641b1..f1730fafa 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 @@ -58,6 +58,10 @@ class LazyLoad final : public data_interfaces::IDataSystem { bool Initialized() const override; + [[nodiscard]] bool CanEvaluateWhenNotInitialized() const override { + return true; + } + // Public for usage in tests. struct Kinds { static integrations::FlagKind const Flag; @@ -187,7 +191,6 @@ class LazyLoad final : public data_interfaces::IDataSystem { mutable data_components::ExpirationTracker tracker_; TimeFn time_; mutable std::optional initialized_; - mutable bool logged_init_warning_{false}; ClockType::duration fresh_duration_; diff --git a/libs/server-sdk/tests/lazy_load_system_test.cpp b/libs/server-sdk/tests/lazy_load_system_test.cpp index b32723691..c2b99bd45 100644 --- a/libs/server-sdk/tests/lazy_load_system_test.cpp +++ b/libs/server-sdk/tests/lazy_load_system_test.cpp @@ -283,7 +283,7 @@ TEST_F(LazyLoadTest, AllSegmentsRefreshesIndividualSegment) { ASSERT_EQ(segment2->version, 2); } -TEST_F(LazyLoadTest, InitializedReturnsTrueEvenWhenSourceNotInitialized) { +TEST_F(LazyLoadTest, InitializeNotQueriedRepeatedly) { built::LazyLoadConfig const config{ built::LazyLoadConfig::EvictionPolicy::Disabled, std::chrono::seconds(10), mock_reader}; @@ -292,10 +292,8 @@ TEST_F(LazyLoadTest, InitializedReturnsTrueEvenWhenSourceNotInitialized) { data_systems::LazyLoad const lazy_load(logger, config, status_manager); - // In lazy load mode, Initialized() always returns true even when the - // underlying source reports not initialized ($inited key not found). for (std::size_t i = 0; i < 10; i++) { - ASSERT_TRUE(lazy_load.Initialized()); + ASSERT_FALSE(lazy_load.Initialized()); } } @@ -331,46 +329,24 @@ TEST_F(LazyLoadTest, InitializeCalledAgainAfterTTL) { data_systems::LazyLoad const lazy_load(logger, config, status_manager, [&]() { return now; }); - // Always returns true even when source reports not initialized. for (std::size_t i = 0; i < 10; i++) { - ASSERT_TRUE(lazy_load.Initialized()); + ASSERT_FALSE(lazy_load.Initialized()); now += std::chrono::seconds(1); } - // Still true after TTL when source now reports initialized. for (std::size_t i = 0; i < 10; i++) { ASSERT_TRUE(lazy_load.Initialized()); } } -TEST_F(LazyLoadTest, InitializedLogsWarningWhenSourceNotInitialized) { - built::LazyLoadConfig const config{ - built::LazyLoadConfig::EvictionPolicy::Disabled, - std::chrono::seconds(10), mock_reader}; - - EXPECT_CALL(*mock_reader, Initialized).WillOnce(Return(false)); - - data_systems::LazyLoad const lazy_load(logger, config, status_manager); - - ASSERT_TRUE(lazy_load.Initialized()); - - // A warning should have been logged about $inited not being found. - ASSERT_TRUE(spy_logger_backend->Contains( - 0, LogLevel::kWarn, "$inited")); -} - -TEST_F(LazyLoadTest, InitializedDoesNotLogWarningWhenSourceIsInitialized) { +TEST_F(LazyLoadTest, CanEvaluateWhenNotInitialized) { built::LazyLoadConfig const config{ built::LazyLoadConfig::EvictionPolicy::Disabled, std::chrono::seconds(10), mock_reader}; - EXPECT_CALL(*mock_reader, Initialized).WillOnce(Return(true)); - data_systems::LazyLoad const lazy_load(logger, config, status_manager); - ASSERT_TRUE(lazy_load.Initialized()); - - // No warning should be logged when source is properly initialized. - // Only debug-level messages should be present (or none at all). - ASSERT_TRUE(spy_logger_backend->Count(0)); + // LazyLoad can always serve evaluations on demand, even if not + // initialized (i.e. $inited key not found in store). + ASSERT_TRUE(lazy_load.CanEvaluateWhenNotInitialized()); }