Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/actions/ci/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
Expand All @@ -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 }}
Expand Down
58 changes: 58 additions & 0 deletions .github/workflows/server-redis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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
32 changes: 31 additions & 1 deletion contract-tests/data-model/include/data_model/data_model.hpp
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
#pragma once

#include <optional>
Expand Down Expand Up @@ -150,6 +150,34 @@

NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigWrapper, name, version);

struct ConfigPersistentCache {
std::string mode; // "off", "ttl", "infinite"
std::optional<int> 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<std::string> 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<uint32_t> startWaitTimeMs;
Expand All @@ -164,6 +192,7 @@
std::optional<ConfigProxyParams> proxy;
std::optional<ConfigHooksParams> hooks;
std::optional<ConfigWrapper> wrapper;
std::optional<ConfigPersistentDataStore> persistentDataStore;
};

NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(ConfigParams,
Expand All @@ -179,7 +208,8 @@
tls,
proxy,
hooks,
wrapper);
wrapper,
persistentDataStore);

struct ContextSingleParams {
std::optional<std::string> kind;
Expand Down
5 changes: 5 additions & 0 deletions contract-tests/server-contract-tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
54 changes: 54 additions & 0 deletions contract-tests/server-contract-tests/src/entity_manager.cpp
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
#include "entity_manager.hpp"
#include "contract_test_hook.hpp"

Expand All @@ -7,6 +7,10 @@

#include <boost/json.hpp>

#ifdef LD_REDIS_SUPPORT_ENABLED
#include <launchdarkly/server_side/integrations/redis/redis_source.hpp>
#endif

using launchdarkly::LogLevel;
using namespace launchdarkly::server_side;

Expand Down Expand Up @@ -154,6 +158,56 @@
}
}

#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));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TTL value likely treated as wrong time unit

High Severity

The CacheRefresh API accepts std::chrono::milliseconds, and the LaunchDarkly SDK contract test harness protocol sends the ttl value in milliseconds. However, the code wraps the raw ttl integer in std::chrono::seconds, which implicitly converts to milliseconds by multiplying by 1000. This makes the effective cache TTL 1000× larger than intended (e.g., a harness-sent value of 30000 ms becomes 30,000 seconds instead of 30 seconds). The data model comment also incorrectly states the unit is seconds. The value likely needs to be wrapped in std::chrono::milliseconds instead.

Additional Locations (1)
Fix in Cursor Fix in Web

}
} 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)
Expand Down
3 changes: 3 additions & 0 deletions contract-tests/server-contract-tests/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down
27 changes: 22 additions & 5 deletions libs/server-sdk/src/client_impl.cpp
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
#include "client_impl.hpp"

#include "all_flags_state/all_flags_state_builder.hpp"
Expand Down Expand Up @@ -186,11 +186,19 @@
std::unordered_map<Client::FlagKey, Value> 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};
Expand Down Expand Up @@ -418,7 +426,16 @@
std::optional<enum EvaluationReason::ErrorKind> 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.";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning logged on every evaluation in lazy load

Medium Severity

In PreEvaluationChecks, a verbose warning is logged on every single evaluation call when Initialized() returns false and CanEvaluateWhenNotInitialized() is true. In LazyLoad mode where $inited is never set (common without Relay Proxy), this produces a warning on every flag evaluation, potentially thousands per second. Same issue exists in AllFlagsState. This warning likely needs to be logged only once or rate-limited.

Additional Locations (1)
Fix in Cursor Fix in Web

} else {
return EvaluationReason::ErrorKind::kClientNotReady;
}
}
if (!context.Valid()) {
return EvaluationReason::ErrorKind::kUserNotSpecified;
Expand Down
16 changes: 16 additions & 0 deletions libs/server-sdk/src/data_interfaces/system/idata_system.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions libs/server-sdk/tests/lazy_load_system_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
8 changes: 8 additions & 0 deletions scripts/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ..
Expand All @@ -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
Expand Down
Loading