diff --git a/.github/actions/ci/action.yml b/.github/actions/ci/action.yml index 35c1ca23b..a1e30de49 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). Leave unset to preserve automatic detection for Redis targets.' + required: false + default: '' 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-redis.yml b/.github/workflows/server-redis.yml index 0a73c4786..0a88a059b 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.3.0 + - uses: launchdarkly/gh-actions/actions/contract-tests@5adb11fd6953e1bc35d9cf1fc1b4374c464e3a8b + 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.3.0 + - uses: launchdarkly/gh-actions/actions/contract-tests@5adb11fd6953e1bc35d9cf1fc1b4374c464e3a8b + 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: @@ -28,6 +83,7 @@ jobs: with: cmake_target: launchdarkly-cpp-server-redis-source simulate_release: true + use_redis: true build-redis-mac: runs-on: macos-15 steps: @@ -39,6 +95,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 +114,4 @@ jobs: toolset: msvc run_tests: false # TODO: figure out how to run Redis service on Windows simulate_windows_release: true + use_redis: true 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..d05d8c73f 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 ttl; // TTL in seconds (sent by test harness as "ttl") +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigPersistentCache, + mode, + ttl); + +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..63e095481 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 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.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 + 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/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.hpp b/libs/server-sdk/src/data_systems/lazy_load/lazy_load_system.hpp index 9584f60d6..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; diff --git a/libs/server-sdk/tests/lazy_load_system_test.cpp b/libs/server-sdk/tests/lazy_load_system_test.cpp index f908a8ac8..c2b99bd45 100644 --- a/libs/server-sdk/tests/lazy_load_system_test.cpp +++ b/libs/server-sdk/tests/lazy_load_system_test.cpp @@ -338,3 +338,15 @@ TEST_F(LazyLoadTest, InitializeCalledAgainAfterTTL) { ASSERT_TRUE(lazy_load.Initialized()); } } + +TEST_F(LazyLoadTest, CanEvaluateWhenNotInitialized) { + built::LazyLoadConfig const config{ + built::LazyLoadConfig::EvictionPolicy::Disabled, + std::chrono::seconds(10), mock_reader}; + + data_systems::LazyLoad const lazy_load(logger, config, status_manager); + + // LazyLoad can always serve evaluations on demand, even if not + // initialized (i.e. $inited key not found in store). + ASSERT_TRUE(lazy_load.CanEvaluateWhenNotInitialized()); +} diff --git a/scripts/build.sh b/scripts/build.sh index 7fd27a47e..f766f0583 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 .. @@ -31,6 +32,13 @@ 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 + # Set build type to Debug when testing is enabled build_type="Release" if [ "$2" == "ON" ]; then