diff --git a/.github/ISSUE_TEMPLATE/server-sdk-redis-source--bug-report.md b/.github/ISSUE_TEMPLATE/server-sdk-redis-source--bug-report.md new file mode 100644 index 000000000..9306f473d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/server-sdk-redis-source--bug-report.md @@ -0,0 +1,50 @@ +--- +name: 'C++ Server SDK / Redis Source Bug Report' +about: Create a report to help us improve +title: '' +labels: 'package: sdk/server-redis, bug' +assignees: '' + +--- + +**Is this a support request?** +This issue tracker is maintained by LaunchDarkly SDK developers and is intended for feedback on the code in this +library. If you're not sure whether the problem you are having is specifically related to this library, or to the +LaunchDarkly service overall, it may be more appropriate to contact the LaunchDarkly support team; they can help to +investigate the problem and will consult the SDK team if necessary. You can submit a support request by +going [here](https://support.launchdarkly.com/) and clicking "submit a request", or by emailing +support@launchdarkly.com. + +Note that issues filed on this issue tracker are publicly accessible. Do not provide any private account information on +your issues. If your problem is specific to your account, you should submit a support request as described above. + +**Describe the bug** +A clear and concise description of what the bug is. + +**To reproduce** +Steps to reproduce the behavior. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Logs** +If applicable, add any log output related to your problem. +To get more logs from the SDK, change the log level using environment variable `LD_LOG_LEVEL`. For example: + + ``` + LD_LOG_LEVEL=debug ./your-application + ``` + +**SDK version** +The version of this SDK that you are using. + +**Language version, developer tools** +For instance, C++17 or C11. If you are using a language that requires a separate compiler, such as C, please include the +name and version of the compiler too. + +**OS/platform** +For instance, Ubuntu 16.04, Windows 10, or Android 4.0.3. If your code is running in a browser, please also include the +browser type and version. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/server-sdk-redis-source--feature-request.md b/.github/ISSUE_TEMPLATE/server-sdk-redis-source--feature-request.md new file mode 100644 index 000000000..ec64792b5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/server-sdk-redis-source--feature-request.md @@ -0,0 +1,20 @@ +--- +name: 'C++ Server SDK / Redis Source Feature Request' +about: Create a report to help us improve +title: '' +labels: 'package: sdk/server-redis, feature' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I would love to see the SDK [...does something new...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context about the feature request here. diff --git a/.github/workflows/manual-publish-doc.yml b/.github/workflows/manual-publish-doc.yml index 535888d59..1eb81a308 100644 --- a/.github/workflows/manual-publish-doc.yml +++ b/.github/workflows/manual-publish-doc.yml @@ -9,6 +9,7 @@ on: options: - libs/client-sdk - libs/server-sdk + - libs/server-sdk-redis-source name: Publish Documentation jobs: build-publish: diff --git a/.github/workflows/manual-sdk-release-artifacts.yml b/.github/workflows/manual-sdk-release-artifacts.yml index 99e9f5db7..f5d9c61a3 100644 --- a/.github/workflows/manual-sdk-release-artifacts.yml +++ b/.github/workflows/manual-sdk-release-artifacts.yml @@ -15,6 +15,7 @@ on: options: - libs/client-sdk:launchdarkly-cpp-client - libs/server-sdk:launchdarkly-cpp-server + - libs/server-sdk-redis-source:launchdarkly-cpp-server-redis-source name: Publish SDK Artifacts diff --git a/.github/workflows/server-redis.yml b/.github/workflows/server-redis.yml new file mode 100644 index 000000000..a3c098fd6 --- /dev/null +++ b/.github/workflows/server-redis.yml @@ -0,0 +1,49 @@ +name: libs/server-sdk-redis-source + +on: + push: + branches: [ main ] + paths-ignore: + - '**.md' #Do not need to run CI for markdown changes. + pull_request: + branches: [ "main", "feat/**" ] + paths-ignore: + - '**.md' + +jobs: + build-test-redis: + runs-on: ubuntu-22.04 + services: + redis: + image: redis + ports: + - 6379:6379 + steps: + - uses: actions/checkout@v3 + - uses: ./.github/actions/ci + with: + cmake_target: launchdarkly-cpp-server-redis-source + simulate_release: true + build-redis-mac: + runs-on: macos-12 + steps: + - uses: actions/checkout@v3 + - uses: ./.github/actions/ci + with: + cmake_target: launchdarkly-cpp-server-redis-source + platform_version: 12 + run_tests: false # TODO: figure out how to run Redis service on Mac + build-test-redis-windows: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v3 + - uses: ilammy/msvc-dev-cmd@v1 + - uses: ./.github/actions/ci + env: + BOOST_LIBRARY_DIR: 'C:\local\boost_1_81_0\lib64-msvc-14.3' + BOOST_LIBRARYDIR: 'C:\local\boost_1_81_0\lib64-msvc-14.3' + with: + cmake_target: launchdarkly-cpp-server-redis-source + platform_version: 2022 + toolset: msvc + run_tests: false # TODO: figure out how to run Redis service on Windows diff --git a/CMakeLists.txt b/CMakeLists.txt index c7921f9cf..c40e33cc2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -61,6 +61,8 @@ option(LD_DYNAMIC_LINK_OPENSSL option(LD_BUILD_EXAMPLES "Build hello-world examples." ON) +option(LD_BUILD_REDIS_SUPPORT "Build redis support." OFF) + # If using 'make' as the build system, CMake causes the 'install' target to have a dependency on 'all', meaning # it will cause a full build. This disables that, allowing us to build piecemeal instead. This is useful # so that we only need to build the client or server for a given release (if only the client or server were affected.) @@ -142,6 +144,11 @@ add_subdirectory(libs/common) add_subdirectory(libs/internal) add_subdirectory(libs/server-sent-events) +if (LD_BUILD_REDIS_SUPPORT) + message("LaunchDarkly: building server-side redis support") + add_subdirectory(libs/server-sdk-redis-source) +endif () + # Built as static or shared depending on LD_BUILD_SHARED_LIBS variable. # This target "links" in common, internal, and sse as object libraries. add_subdirectory(libs/client-sdk) diff --git a/cmake/redis-plus-plus.cmake b/cmake/redis-plus-plus.cmake new file mode 100644 index 000000000..49572b27f --- /dev/null +++ b/cmake/redis-plus-plus.cmake @@ -0,0 +1,29 @@ +cmake_minimum_required(VERSION 3.11) + +include(FetchContent) + + +FetchContent_Declare(hiredis + GIT_REPOSITORY https://github.com/redis/hiredis.git + GIT_TAG 60e5075d4ac77424809f855ba3e398df7aacefe8 + GIT_SHALLOW TRUE + SOURCE_DIR _deps/hiredis + OVERRIDE_FIND_PACKAGE +) + + +FetchContent_MakeAvailable(hiredis) + +include_directories(${CMAKE_CURRENT_BINARY_DIR}/_deps) + +set(REDIS_PLUS_PLUS_BUILD_TEST OFF CACHE BOOL "" FORCE) + +# 1.3.7 is the last release that works with FetchContent, due to a problem with CheckSymbolExists +# when it tries to do feature detection on hiredis. +FetchContent_Declare(redis-plus-plus + GIT_REPOSITORY https://github.com/sewenew/redis-plus-plus.git + GIT_TAG 1.3.7 + GIT_SHALLOW TRUE +) + +FetchContent_MakeAvailable(redis-plus-plus) diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 138ec4942..f013bc666 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -3,3 +3,7 @@ add_subdirectory(hello-cpp-client) add_subdirectory(hello-cpp-server) add_subdirectory(hello-c-server) add_subdirectory(client-and-server-coexistence) + +if (LD_BUILD_REDIS_SUPPORT) + add_subdirectory(hello-cpp-server-redis) +endif () diff --git a/examples/hello-c-server/main.c b/examples/hello-c-server/main.c index 5c1acc69a..f152ad60a 100644 --- a/examples/hello-c-server/main.c +++ b/examples/hello-c-server/main.c @@ -53,7 +53,7 @@ int main() { return 1; } } else { - printf("SDK initialization didn't complete in %dms\n", + printf("*** SDK initialization didn't complete in %dms\n", INIT_TIMEOUT_MILLISECONDS); return 1; } diff --git a/examples/hello-cpp-server-redis/CMakeLists.txt b/examples/hello-cpp-server-redis/CMakeLists.txt new file mode 100644 index 000000000..8d75b1ab4 --- /dev/null +++ b/examples/hello-cpp-server-redis/CMakeLists.txt @@ -0,0 +1,15 @@ +# Required for Apple Silicon support. +cmake_minimum_required(VERSION 3.19) + +project( + LaunchDarklyHelloCPPServerRedisSource + VERSION 0.1 + DESCRIPTION "LaunchDarkly Hello CPP Server-side SDK with Redis source" + LANGUAGES CXX +) + +set(THREADS_PREFER_PTHREAD_FLAG ON) +find_package(Threads REQUIRED) + +add_executable(hello-cpp-server-redis-source main.cpp) +target_link_libraries(hello-cpp-server-redis-source PRIVATE launchdarkly::server_redis_source Threads::Threads) diff --git a/examples/hello-cpp-server-redis/main.cpp b/examples/hello-cpp-server-redis/main.cpp new file mode 100644 index 000000000..f76fc3808 --- /dev/null +++ b/examples/hello-cpp-server-redis/main.cpp @@ -0,0 +1,108 @@ +#include +#include +#include + +#include + +#include +#include + +// Set SDK_KEY to your LaunchDarkly SDK key. +#define SDK_KEY "" + +// Set FEATURE_FLAG_KEY to the feature flag key you want to evaluate. +#define FEATURE_FLAG_KEY "my-boolean-flag" + +// Set INIT_TIMEOUT_MILLISECONDS to the amount of time you will wait for +// the client to become initialized. +#define INIT_TIMEOUT_MILLISECONDS 3000 + +// Set REDIS_URI to your own Redis instance's URI. +#define REDIS_URI "redis://localhost:6379" + +// Set REDIS_PREFIX to the prefix containing the launchdarkly +// environment data in Redis. +#define REDIS_PREFIX "launchdarkly" + +char const* get_with_env_fallback(char const* source_val, + char const* env_variable, + char const* error_msg); +using namespace launchdarkly; +using namespace launchdarkly::server_side; + +int main() { + char const* sdk_key = get_with_env_fallback( + SDK_KEY, "LD_SDK_KEY", + "Please edit main.cpp to set SDK_KEY to your LaunchDarkly server-side " + "SDK key " + "first.\n\nAlternatively, set the LD_SDK_KEY environment " + "variable.\n" + "The value of SDK_KEY in main.c takes priority over LD_SDK_KEY."); + + auto config_builder = ConfigBuilder(sdk_key); + + using LazyLoad = server_side::config::builders::LazyLoadBuilder; + + auto redis = integrations::RedisDataSource::Create(REDIS_URI, REDIS_PREFIX); + + if (!redis) { + std::cout << "error: redis config is invalid: " << redis.error() + << '\n'; + return 1; + } + + config_builder.DataSystem().Method( + LazyLoad().Source(*redis).CacheRefresh(std::chrono::seconds(30))); + + auto config = config_builder.Build(); + if (!config) { + std::cout << "error: config is invalid: " << config.error() << '\n'; + return 1; + } + + auto client = Client(std::move(*config)); + + auto start_result = client.StartAsync(); + + if (auto const status = start_result.wait_for( + std::chrono::milliseconds(INIT_TIMEOUT_MILLISECONDS)); + status == std::future_status::ready) { + if (start_result.get()) { + std::cout << "*** SDK successfully initialized!\n\n"; + } else { + std::cout << "*** SDK failed to initialize\n"; + return 1; + } + } else { + std::cout << "*** SDK initialization didn't complete in " + << INIT_TIMEOUT_MILLISECONDS << "ms\n"; + return 1; + } + + auto const context = + ContextBuilder().Kind("user", "example-user-key").Name("Sandy").Build(); + + bool const flag_value = + client.BoolVariation(context, FEATURE_FLAG_KEY, false); + + std::cout << "*** Feature flag '" << FEATURE_FLAG_KEY << "' is " + << (flag_value ? "true" : "false") << " for this user\n\n"; + + return 0; +} + +char const* get_with_env_fallback(char const* source_val, + char const* env_variable, + char const* error_msg) { + if (strlen(source_val)) { + return source_val; + } + + if (char const* from_env = std::getenv(env_variable); + from_env && strlen(from_env)) { + return from_env; + } + + std::cout << "*** " << error_msg << std::endl; + std::exit(1); +} diff --git a/libs/client-sdk/tests/CMakeLists.txt b/libs/client-sdk/tests/CMakeLists.txt index de8236d5c..5f53df407 100644 --- a/libs/client-sdk/tests/CMakeLists.txt +++ b/libs/client-sdk/tests/CMakeLists.txt @@ -9,8 +9,10 @@ file(GLOB tests "${PROJECT_SOURCE_DIR}/tests/*.cpp") set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}) # Get things in the same directory on windows. -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}../") -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}../") +if (WIN32) + set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}../") + set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}../") +endif () add_executable(gtest_${LIBNAME} ${tests}) diff --git a/libs/client-sdk/tests/data_source_status_manager_test.cpp b/libs/client-sdk/tests/data_source_status_manager_test.cpp index 3ac3c3159..5bfdabbb7 100644 --- a/libs/client-sdk/tests/data_source_status_manager_test.cpp +++ b/libs/client-sdk/tests/data_source_status_manager_test.cpp @@ -2,6 +2,8 @@ #include "data_sources/data_source_status_manager.hpp" +#include + using launchdarkly::client_side::data_sources::DataSourceStatus; using launchdarkly::client_side::data_sources::DataSourceStatusManager; using launchdarkly::client_side::data_sources::IDataSourceStatusProvider; @@ -145,6 +147,8 @@ TEST(DataSourceStatusManagerTests, TimeIsUpdatedOnStateChange) { status_manager.SetState(DataSourceStatus::DataSourceState::kValid); auto initial = status_manager.Status().StateSince(); + + std::this_thread::sleep_for(std::chrono::milliseconds(1)); status_manager.SetState(DataSourceStatus::DataSourceState::kInterrupted); EXPECT_NE(initial.time_since_epoch().count(), diff --git a/libs/common/tests/CMakeLists.txt b/libs/common/tests/CMakeLists.txt index ce5b742a3..88e24a280 100644 --- a/libs/common/tests/CMakeLists.txt +++ b/libs/common/tests/CMakeLists.txt @@ -11,8 +11,10 @@ file(GLOB tests "${PROJECT_SOURCE_DIR}/tests/*.cpp" set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}) # Get things in the same directory on windows. -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}../") -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}../") +if (WIN32) + set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}../") + set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}../") +endif () add_executable(gtest_${LIBNAME} ${tests}) diff --git a/libs/common/tests/data_source_status_test.cpp b/libs/common/tests/data_source_status_test.cpp index c3b165803..6b50ccc74 100644 --- a/libs/common/tests/data_source_status_test.cpp +++ b/libs/common/tests/data_source_status_test.cpp @@ -2,6 +2,8 @@ #include +#include + namespace test_things { enum class TestDataSourceStates { kStateA = 0, kStateB = 1, kStateC = 2 }; diff --git a/libs/internal/tests/CMakeLists.txt b/libs/internal/tests/CMakeLists.txt index 9a9f3aebd..69c9ffe3c 100644 --- a/libs/internal/tests/CMakeLists.txt +++ b/libs/internal/tests/CMakeLists.txt @@ -9,8 +9,10 @@ file(GLOB tests "${PROJECT_SOURCE_DIR}/tests/*.cpp") set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}) # Get things in the same directory on windows. -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}../") -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}../") +if (WIN32) + set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}../") + set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}../") +endif () add_executable(gtest_${LIBNAME} ${tests}) diff --git a/libs/server-sdk-redis-source/CMakeLists.txt b/libs/server-sdk-redis-source/CMakeLists.txt new file mode 100644 index 000000000..58894b4b1 --- /dev/null +++ b/libs/server-sdk-redis-source/CMakeLists.txt @@ -0,0 +1,35 @@ +# This project aims to follow modern cmake guidelines, e.g. +# https://cliutils.gitlab.io/modern-cmake + +# Required for Apple Silicon support. +cmake_minimum_required(VERSION 3.19) + +project( + LaunchDarklyCPPServerRedisSource + VERSION 0.1.0 # {x-release-please-version} + DESCRIPTION "LaunchDarkly C++ Server SDK Redis Source" + LANGUAGES CXX C +) + +set(LIBNAME "launchdarkly-cpp-server-redis-source") + +# If this project is the main CMake project (as opposed to being included via add_subdirectory) +if (CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME) + # Disable C++ extensions for portability. + set(CMAKE_CXX_EXTENSIONS OFF) + # Enable folder support in IDEs. + set_property(GLOBAL PROPERTY USE_FOLDERS ON) +endif () + +# Needed to fetch external dependencies. +include(FetchContent) + +set(REDIS_PLUS_PLUS_BUILD_SHARED OFF CACHE BOOL "" FORCE) + +include(${CMAKE_FILES}/redis-plus-plus.cmake) + +add_subdirectory(src) + +if (LD_BUILD_UNIT_TESTS) + add_subdirectory(tests) +endif () diff --git a/libs/server-sdk-redis-source/Doxyfile b/libs/server-sdk-redis-source/Doxyfile new file mode 100644 index 000000000..abe5e5df6 --- /dev/null +++ b/libs/server-sdk-redis-source/Doxyfile @@ -0,0 +1,94 @@ +# Doxyfile 1.8.17 + +# This file describes the settings to be used by the documentation system +# doxygen (www.doxygen.org) for a project. +# +# All text after a double hash (##) is considered a comment and is placed in +# front of the TAG it is preceding. +# +# All text after a single hash (#) is considered a comment and will be ignored. +# The format is: +# TAG = value [value, ...] +# For lists, items can also be appended using: +# TAG += value [value, ...] +# Values that contain spaces should be placed between quotes (\" \"). + +#--------------------------------------------------------------------------- +# Project related configuration options +#--------------------------------------------------------------------------- + +# This tag specifies the encoding used for all characters in the configuration +# file that follow. The default is UTF-8 which is also the encoding used for all +# text before the first occurrence of this tag. Doxygen uses libiconv (or the +# iconv built into libc) for the transcoding. See +# https://www.gnu.org/software/libiconv/ for the list of possible encodings. +# The default value is: UTF-8. + +DOXYFILE_ENCODING = UTF-8 + +# The PROJECT_NAME tag is a single word (or a sequence of words surrounded by +# double-quotes, unless you are using Doxywizard) that should identify the +# project for which the documentation is generated. This name is used in the +# title of most generated pages and in a few other places. +# The default value is: My Project. + +PROJECT_NAME = "C++ Server-Side SDK Redis Source" + +# Using the PROJECT_BRIEF tag one can provide an optional one line description +# for a project that appears at the top of each page and should give viewer a +# quick idea about the purpose of the project. Keep the description short. + +PROJECT_BRIEF = "Provide SDK data via Redis" + +# The OUTPUT_DIRECTORY tag is used to specify the (relative or absolute) path +# into which the generated documentation will be written. If a relative path is +# entered, it will be relative to the location where doxygen was started. If +# left blank the current directory will be used. + +OUTPUT_DIRECTORY = docs + +# If you use STL classes (i.e. std::string, std::vector, etc.) but do not want +# to include (a tag file for) the STL sources as input, then you should set this +# tag to YES in order to let doxygen match functions declarations and +# definitions whose arguments contain STL classes (e.g. func(std::string); +# versus func(std::string) {}). This also make the inheritance and collaboration +# diagrams that involve STL classes more complete and accurate. +# The default value is: NO. + +BUILTIN_STL_SUPPORT = YES + +# When TYPEDEF_HIDES_STRUCT tag is enabled, a typedef of a struct, union, or +# enum is documented as struct, union, or enum with the name of the typedef. So +# typedef struct TypeS {} TypeT, will appear in the documentation as a struct +# with name TypeT. When disabled the typedef will appear as a member of a file, +# namespace, or class. And the struct will be named TypeS. This can typically be +# useful for C code in case the coding convention dictates that all compound +# types are typedef'ed and only the typedef is referenced, never the tag name. +# The default value is: NO. + +TYPEDEF_HIDES_STRUCT = YES + +#--------------------------------------------------------------------------- +# Configuration options related to the input files +#--------------------------------------------------------------------------- + +# The INPUT tag is used to specify the files and/or directories that contain +# documented source files. You may enter file names like myfile.cpp or +# directories like /usr/src/myproject. Separate the files or directories with +# spaces. See also FILE_PATTERNS and EXTENSION_MAPPING +# Note: If this tag is empty the current directory is searched. + +INPUT = include src docs ../common/include ../common/src + +# The RECURSIVE tag can be used to specify whether or not subdirectories should +# be searched for input files as well. +# The default value is: NO. + +RECURSIVE = YES + +# If the USE_MDFILE_AS_MAINPAGE tag refers to the name of a markdown file that +# is part of the input, its contents will be placed on the main page +# (index.html). This can be useful if you have a project on for instance GitHub +# and want to reuse the introduction page also for the doxygen output. + +USE_MDFILE_AS_MAINPAGE = ./docs/doc.md diff --git a/libs/server-sdk-redis-source/README.md b/libs/server-sdk-redis-source/README.md new file mode 100644 index 000000000..9c70c6bfe --- /dev/null +++ b/libs/server-sdk-redis-source/README.md @@ -0,0 +1,124 @@ +LaunchDarkly Server-Side SDK - Redis Source for C/C++ +=================================== + +### ⚠️ This repository contains alpha software and should not be considered ready for production use while this message is visible. + +### Breaking changes may occur. + +[![Actions Status](https://github.com/launchdarkly/cpp-sdks/actions/workflows/server-redis.yml/badge.svg)](https://github.com/launchdarkly/cpp-sdks/actions/workflows/server.yml) +[![Documentation](https://img.shields.io/static/v1?label=GitHub+Pages&message=API+reference&color=00add8)](https://launchdarkly.github.io/cpp-sdks/libs/server-sdk-redis-source/docs/html/) + +The LaunchDarkly Server-Side SDK Redis Source for C/C++ is designed for use with the Server-Side SDK. + +This component allows the Server-Side SDK to retrieve feature flag configurations from Redis, rather than +from LaunchDarkly. + +LaunchDarkly overview +------------------------- +[LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves trillions of feature flags +daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/docs/getting-started) +using LaunchDarkly today! + +[![Twitter Follow](https://img.shields.io/twitter/follow/launchdarkly.svg?style=social&label=Follow&maxAge=2592000)](https://twitter.com/intent/follow?screen_name=launchdarkly) + +Compatibility +------------------------- + +This component is compatible with POSIX environments (Linux, OS X, BSD) and Windows. + +Getting started +--------------- + +Download a release archive from +the [Github releases](https://github.com/launchdarkly/cpp-sdks/releases?q=cpp-server-redis&expanded=true) for use in +your project. + +Refer to the [SDK documentation][reference-guide] for complete instructions on +installing and using the SDK. + +### Incorporating the Redis Source + +The component can be used via a C++ or C interface and can be incorporated via a static library or shared object. The +static library and shared object each have their own use cases and limitations. + +The static library supports both the C++ and C interface. When using the static library, you should ensure that it is +compiled using a compatible configuration and toolchain. For instance, when using MSVC, it needs to be using the same +runtime library. + +The C++ API does not have a stable ABI, so if this is important to you, consider using the shared object with the C API. + +The shared library (so, DLL, dylib), only supports the C interface. + +The examples here are to help with getting started, but generally speaking this component should be incorporated using +your +build system (CMake for instance). + +### CMake Usage + +When configuring the SDK via CMake, you need to explicitly enable this component (example): + +``` +cmake -GNinja -D LD_BUILD_REDIS_SUPPORT=ON .. +``` + +It is disabled by default to avoid pulling in the `redis++` and `hiredis` dependencies that this component is +implemented with. + +This will expose the `launchdarkly::server_redis_source` target. + +Next, link the target to your executable or library: + +```cmake +target_link_libraries(my-target PRIVATE launchdarkly::server_redis_source) +``` + +This will cause `launchdarkly::server` to be linked as well. + +Learn more +----------- + +Read our [documentation](https://docs.launchdarkly.com) for in-depth instructions on configuring and using LaunchDarkly. +You can also head straight to +the [complete reference guide for this SDK][reference-guide]. + +Testing +------- + +We run integration tests for all our SDKs using a centralized test harness. This approach gives us the ability to test +for consistency across SDKs, as well as test networking behavior in a long-running application. These tests cover each +method in the SDK, and verify that event sending, flag evaluation, stream reconnection, and other aspects of the SDK all +behave correctly. + +Contributing +------------ + +We encourage pull requests and other contributions from the community. Read +our [contributing guidelines](../../CONTRIBUTING.md) for instructions on how to contribute to this SDK. + +About LaunchDarkly +----------- + +* LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to + iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. + With LaunchDarkly, you can: + * Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), + gathering feedback and bug reports from real-world use cases. + * Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on + key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). + * Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, + or even restart the application with a changed configuration file. + * Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get + access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate + maintenance, without taking everything offline. +* LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. + Read [our documentation](https://docs.launchdarkly.com/docs) for a complete list. +* Explore LaunchDarkly + * [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information + * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and + SDK reference guides + * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API + documentation + * [blog.launchdarkly.com](https://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product + updates + +[reference-guide]: https://docs.launchdarkly.com/sdk/server-side/c-c-- diff --git a/libs/server-sdk-redis-source/include/launchdarkly/server_side/integrations/redis/redis_source.hpp b/libs/server-sdk-redis-source/include/launchdarkly/server_side/integrations/redis/redis_source.hpp new file mode 100644 index 000000000..661f4ebfd --- /dev/null +++ b/libs/server-sdk-redis-source/include/launchdarkly/server_side/integrations/redis/redis_source.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include + +#include + +#include +#include + +namespace sw::redis { +class Redis; +} + +namespace launchdarkly::server_side::integrations { + +class RedisDataSource final : public ISerializedDataReader { + public: + static tl::expected, std::string> Create( + std::string uri, + std::string prefix); + + [[nodiscard]] GetResult Get(ISerializedItemKind const& kind, + std::string const& itemKey) const override; + [[nodiscard]] AllResult All( + integrations::ISerializedItemKind const& kind) const override; + [[nodiscard]] std::string const& Identity() const override; + [[nodiscard]] bool Initialized() const override; + + ~RedisDataSource(); + + private: + RedisDataSource(std::unique_ptr redis, + std::string prefix); + + std::string key_for_kind(ISerializedItemKind const& kind) const; + + std::string const prefix_; + std::string const inited_key_; + std::unique_ptr redis_; +}; + +} // namespace launchdarkly::server_side::integrations diff --git a/libs/server-sdk-redis-source/src/CMakeLists.txt b/libs/server-sdk-redis-source/src/CMakeLists.txt new file mode 100644 index 000000000..2c2b39603 --- /dev/null +++ b/libs/server-sdk-redis-source/src/CMakeLists.txt @@ -0,0 +1,49 @@ +file(GLOB HEADER_LIST CONFIGURE_DEPENDS + "${LaunchDarklyCPPServerRedisSource_SOURCE_DIR}/include/launchdarkly/server_side/integrations/redis/*.hpp" +) + +if (LD_BUILD_SHARED_LIBS) + message(STATUS "LaunchDarkly: building server-sdk-redis-source as shared library") + add_library(${LIBNAME} SHARED) +else () + message(STATUS "LaunchDarkly: building server-sdk-redis-source as static library") + add_library(${LIBNAME} STATIC) +endif () + +target_sources(${LIBNAME} + PRIVATE + ${HEADER_LIST} + redis_source.cpp +) + + +target_link_libraries(${LIBNAME} + PUBLIC launchdarkly::server + PRIVATE + redis++::redis++_static +) + + +add_library(launchdarkly::server_redis_source ALIAS ${LIBNAME}) + +set_property(TARGET ${LIBNAME} PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") + +# Optional in case only the client SDK is being built. +install(TARGETS ${LIBNAME} OPTIONAL) +if (LD_BUILD_SHARED_LIBS AND MSVC) + install(FILES $ DESTINATION bin OPTIONAL) +endif () + +# Using PUBLIC_HEADERS would flatten the include. +# This will preserve it, but dependencies must do the same. + +install(DIRECTORY "${LaunchDarklyCPPServerRedisSource_SOURCE_DIR}/include/launchdarkly" + DESTINATION "include" +) + +# Need the public headers to build. +target_include_directories(${LIBNAME} PUBLIC ../include) + +# Minimum C++ standard needed for consuming the public API is C++17. +target_compile_features(${LIBNAME} PUBLIC cxx_std_17) diff --git a/libs/server-sdk-redis-source/src/redis_source.cpp b/libs/server-sdk-redis-source/src/redis_source.cpp new file mode 100644 index 000000000..0e8ccdf46 --- /dev/null +++ b/libs/server-sdk-redis-source/src/redis_source.cpp @@ -0,0 +1,76 @@ +#include + +#include + +namespace launchdarkly::server_side::integrations { + +tl::expected, std::string> +RedisDataSource::Create(std::string uri, std::string prefix) { + try { + return std::shared_ptr(new RedisDataSource( + std::make_unique(std::move(uri)), + std::move(prefix))); + } catch (sw::redis::Error const& e) { + return tl::make_unexpected(e.what()); + } +} + +std::string RedisDataSource::key_for_kind( + ISerializedItemKind const& kind) const { + return prefix_ + ":" + kind.Namespace(); +} + +RedisDataSource::RedisDataSource(std::unique_ptr redis, + std::string prefix) + : prefix_(std::move(prefix)), + inited_key_(prefix_ + ":$inited"), + redis_(std::move(redis)) {} + +RedisDataSource::~RedisDataSource() = default; + +ISerializedDataReader::GetResult RedisDataSource::Get( + ISerializedItemKind const& kind, + std::string const& itemKey) const { + try { + if (auto maybe_item = redis_->hget(key_for_kind(kind), itemKey)) { + return SerializedItemDescriptor::Present( + 0, std::move(maybe_item.value())); + } + return std::nullopt; + } catch (sw::redis::Error const& e) { + return tl::make_unexpected(Error{e.what()}); + } +} + +ISerializedDataReader::AllResult RedisDataSource::All( + ISerializedItemKind const& kind) const { + std::unordered_map raw_items; + AllResult::value_type items; + + try { + redis_->hgetall(key_for_kind(kind), + std::inserter(raw_items, raw_items.begin())); + for (auto const& [key, val] : raw_items) { + items.emplace(key, SerializedItemDescriptor::Present(0, val)); + } + return items; + + } catch (sw::redis::Error const& e) { + return tl::make_unexpected(Error{e.what()}); + } +} + +std::string const& RedisDataSource::Identity() const { + static std::string const identity = "redis"; + return identity; +} + +bool RedisDataSource::Initialized() const { + try { + return redis_->exists(inited_key_) == 1; + } catch (sw::redis::Error const& e) { + return false; + } +} + +} // namespace launchdarkly::server_side::integrations diff --git a/libs/server-sdk-redis-source/tests/CMakeLists.txt b/libs/server-sdk-redis-source/tests/CMakeLists.txt new file mode 100644 index 000000000..eddbc3c16 --- /dev/null +++ b/libs/server-sdk-redis-source/tests/CMakeLists.txt @@ -0,0 +1,37 @@ +cmake_minimum_required(VERSION 3.10) +include(GoogleTest) + +include_directories("${PROJECT_SOURCE_DIR}/include") +include_directories("${PROJECT_SOURCE_DIR}/src") + +file(GLOB tests "${PROJECT_SOURCE_DIR}/tests/*.cpp") + +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}) + +# Get things in the same directory on windows. +if (WIN32) + set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}../") + set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}../") +endif () + +add_executable(gtest_${LIBNAME} + ${tests} +) + +# Suppress gtest warning about uninitialized variable. +set_target_properties(gtest PROPERTIES COMPILE_WARNING_AS_ERROR OFF) + +set(LIBS + launchdarkly::server_redis_source + # Needed so we can access the flag/segment data models so we can serialize them for putting in redis. + launchdarkly::internal + # Needed because the source doesn't (need to) expose redis++ as a public dependency, but we need to construct + # a redis client in the tests. + redis++::redis++_static + GTest::gtest_main + GTest::gmock +) + +target_link_libraries(gtest_${LIBNAME} PRIVATE ${LIBS}) + +gtest_discover_tests(gtest_${LIBNAME}) diff --git a/libs/server-sdk-redis-source/tests/redis_source_test.cpp b/libs/server-sdk-redis-source/tests/redis_source_test.cpp new file mode 100644 index 000000000..b54c87aa7 --- /dev/null +++ b/libs/server-sdk-redis-source/tests/redis_source_test.cpp @@ -0,0 +1,456 @@ +#include + +#include +#include + +#include +#include + +#include + +#include + +using namespace launchdarkly::server_side::integrations; +using namespace launchdarkly::data_model; + +class PrefixedClient { + public: + PrefixedClient(sw::redis::Redis& client, std::string const& prefix) + : client_(client), prefix_(prefix) {} + + void Init() const { + try { + client_.set(Prefixed("$inited"), "true"); + } catch (sw::redis::Error const& e) { + FAIL() << e.what(); + } + } + + void PutFlag(Flag const& flag) const { + try { + client_.hset(Prefixed("features"), flag.key, + serialize(boost::json::value_from(flag))); + } catch (sw::redis::Error const& e) { + FAIL() << e.what(); + } + } + + void PutDeletedFlag(std::string const& key, std::string const& ts) const { + try { + client_.hset(Prefixed("features"), key, ts); + } catch (sw::redis::Error const& e) { + FAIL() << e.what(); + } + } + + void PutDeletedSegment(std::string const& key, + std::string const& ts) const { + try { + client_.hset(Prefixed("segments"), key, ts); + } catch (sw::redis::Error const& e) { + FAIL() << e.what(); + } + } + + void PutSegment(Segment const& segment) const { + try { + client_.hset(Prefixed("segments"), segment.key, + serialize(boost::json::value_from(segment))); + } catch (sw::redis::Error const& e) { + FAIL() << e.what(); + } + } + + private: + std::string Prefixed(std::string const& name) const { + return prefix_ + ":" + name; + } + + sw::redis::Redis& client_; + std::string const& prefix_; +}; + +class RedisTests : public ::testing::Test { + public: + explicit RedisTests() + : uri_("tcp://localhost:6379"), prefix_("testprefix"), client_(uri_) {} + + void SetUp() override { + try { + client_.flushdb(); + } catch (sw::redis::Error const& e) { + FAIL() << "couldn't clear Redis: " << e.what(); + } + + auto maybe_source = RedisDataSource::Create(uri_, prefix_); + ASSERT_TRUE(maybe_source); + source = std::move(*maybe_source); + } + + void Init() { + auto const client = PrefixedClient(client_, prefix_); + client.Init(); + } + + void PutFlag(Flag const& flag) { + auto const client = PrefixedClient(client_, prefix_); + client.PutFlag(flag); + } + + void PutDeletedFlag(std::string const& key, std::string const& ts) { + auto const client = PrefixedClient(client_, prefix_); + client.PutDeletedFlag(key, ts); + } + + void PutDeletedSegment(std::string const& key, std::string const& ts) { + auto const client = PrefixedClient(client_, prefix_); + client.PutDeletedSegment(key, ts); + } + + void PutSegment(Segment const& segment) { + auto const client = PrefixedClient(client_, prefix_); + client.PutSegment(segment); + } + + void WithPrefixedClient( + std::string const& prefix, + std::function const& f) { + auto const client = PrefixedClient(client_, prefix); + f(client); + } + + void WithPrefixedSource( + std::string const& prefix, + std::function const& f) const { + auto maybe_source = RedisDataSource::Create(uri_, prefix); + ASSERT_TRUE(maybe_source); + f((*maybe_source->get())); + } + + protected: + std::shared_ptr source; + + private: + std::string const uri_; + std::string const prefix_; + sw::redis::Redis client_; +}; + +TEST_F(RedisTests, RedisEmptyIsNotInitialized) { + ASSERT_FALSE(source->Initialized()); + + auto all_flags = source->All(FlagKind{}); + ASSERT_TRUE(all_flags.has_value()); + ASSERT_TRUE(all_flags->empty()); + + auto all_segments = source->All(SegmentKind{}); + ASSERT_TRUE(all_segments.has_value()); + ASSERT_TRUE(all_flags->empty()); +} + +TEST_F(RedisTests, ChecksInitialized) { + ASSERT_FALSE(source->Initialized()); + Init(); + ASSERT_TRUE(source->Initialized()); +} + +TEST_F(RedisTests, GetFlag) { + Flag const flag{"foo", 1, true}; + PutFlag(flag); + + auto const result = source->Get(FlagKind{}, "foo"); + ASSERT_TRUE(result); + + if (auto const f = *result) { + ASSERT_EQ(f->serializedItem, serialize(boost::json::value_from(flag))); + } else { + FAIL() << "expected flag to be found"; + } +} + +TEST_F(RedisTests, GetSegment) { + Segment const segment{"foo", 1}; + PutSegment(segment); + + auto const result = source->Get(SegmentKind{}, "foo"); + ASSERT_TRUE(result); + + if (auto const f = *result) { + ASSERT_EQ(f->serializedItem, + serialize(boost::json::value_from(segment))); + } else { + FAIL() << "expected segment to be found"; + } +} + +TEST_F(RedisTests, GetMissingFlag) { + auto const result = source->Get(FlagKind{}, "foo"); + ASSERT_TRUE(result); + ASSERT_FALSE(*result); +} + +TEST_F(RedisTests, GetMissingSegment) { + auto const result = source->Get(SegmentKind{}, "foo"); + ASSERT_TRUE(result); + ASSERT_FALSE(*result); +} + +TEST_F(RedisTests, GetDeletedFlag) { + PutDeletedFlag("foo", "foo_tombstone"); + + auto const result = source->Get(FlagKind{}, "foo"); + ASSERT_TRUE(result); + + if (auto const f = *result) { + ASSERT_EQ(f->serializedItem, "foo_tombstone"); + } else { + FAIL() << "expected tombstone to be present"; + } +} + +TEST_F(RedisTests, GetDeletedSegment) { + PutDeletedSegment("foo", "foo_tombstone"); + + auto const result = source->Get(SegmentKind{}, "foo"); + ASSERT_TRUE(result); + + if (auto const f = *result) { + ASSERT_EQ(f->serializedItem, "foo_tombstone"); + } else { + FAIL() << "expected tombstone to be present"; + } +} + +TEST_F(RedisTests, GetFlagDoesNotFindSegment) { + PutSegment(Segment{"foo", 1}); + + auto const result = source->Get(FlagKind{}, "foo"); + ASSERT_TRUE(result); + ASSERT_FALSE(*result); +} + +TEST_F(RedisTests, GetSegmentDoesNotFindFlag) { + PutFlag(Flag{"foo", 1, true}); + + auto const result = source->Get(SegmentKind{}, "foo"); + ASSERT_TRUE(result); + ASSERT_FALSE(*result); +} + +TEST_F(RedisTests, GetAllSegmentsWhenEmpty) { + auto const result = source->All(SegmentKind{}); + ASSERT_TRUE(result); + ASSERT_TRUE(result->empty()); +} + +TEST_F(RedisTests, GetAllFlagsWhenEmpty) { + auto const result = source->All(FlagKind{}); + ASSERT_TRUE(result); + ASSERT_TRUE(result->empty()); +} + +TEST_F(RedisTests, GetAllFlags) { + Flag const flag1{"foo", 1, true}; + Flag const flag2{"bar", 2, false}; + + PutFlag(flag1); + PutFlag(flag2); + PutDeletedFlag("baz", "baz_tombstone"); + + auto const result = source->All(FlagKind{}); + ASSERT_TRUE(result); + ASSERT_EQ(result->size(), 3); + + auto const& flags = *result; + auto const flag1_it = flags.find("foo"); + ASSERT_NE(flag1_it, flags.end()); + ASSERT_EQ(flag1_it->second.serializedItem, + serialize(boost::json::value_from(flag1))); + + auto const flag2_it = flags.find("bar"); + ASSERT_NE(flag2_it, flags.end()); + ASSERT_EQ(flag2_it->second.serializedItem, + serialize(boost::json::value_from(flag2))); + + auto const flag3_it = flags.find("baz"); + ASSERT_NE(flag3_it, flags.end()); + ASSERT_EQ(flag3_it->second.serializedItem, "baz_tombstone"); +} + +TEST_F(RedisTests, InitializedPrefixIndependence) { + WithPrefixedClient("not_our_prefix", [&](auto const& client) { + client.Init(); + ASSERT_FALSE(source->Initialized()); + }); + + WithPrefixedClient("TestPrefix", [&](auto const& client) { + client.Init(); + ASSERT_FALSE(source->Initialized()); + }); + + WithPrefixedClient("stillnotprefix", [&](auto const& client) { + client.Init(); + ASSERT_FALSE(source->Initialized()); + }); +} + +TEST_F(RedisTests, SegmentPrefixIndependence) { + auto MakeSegment = [](std::uint64_t const version) { + return Segment{"foo", version}; + }; + + auto PrefixName = [](std::uint64_t const version) { + return "prefix" + std::to_string(version); + }; + + auto ValidateSegment = [&](ISerializedDataReader::GetResult const& result, + std::size_t i) { + ASSERT_TRUE(result); + if (auto const f = *result) { + ASSERT_EQ(f->serializedItem, + serialize(boost::json::value_from(MakeSegment(i)))); + } else { + FAIL() << "expected segment to be found under " << PrefixName(i); + } + }; + + constexpr std::size_t kPrefixCount = 10; + + // Setup the same segment key (with different versions) under kPrefixCount + // prefixes. This will allow us to verify that the prefixed clients only + // "see" the single segment and not the ones living under different + // prefixes. + + for (std::size_t i = 0; i < kPrefixCount; i++) { + WithPrefixedClient(PrefixName(i), [&](auto const& client) { + client.PutSegment(MakeSegment(i)); + }); + } + + for (std::size_t i = 0; i < kPrefixCount; i++) { + WithPrefixedSource(PrefixName(i), [&](auto const& source) { + // Checks that the string that was stored for segment #i is the + // same one that was retrieved. + ValidateSegment(source.Get(SegmentKind{}, "foo"), i); + + // Sanity check that the other segments are not visible. + auto all = source.All(SegmentKind{}); + ASSERT_TRUE(all); + ASSERT_EQ(all->size(), 1); + }); + } +} + +TEST_F(RedisTests, FlagPrefixIndependence) { + auto MakeFlag = [](std::uint64_t const version) { + return Flag{"foo", version, true}; + }; + + auto PrefixName = [](std::uint64_t const version) { + return "prefix" + std::to_string(version); + }; + + auto ValidateFlag = [&](ISerializedDataReader::GetResult const& result, + std::size_t i) { + ASSERT_TRUE(result); + if (auto const f = *result) { + ASSERT_EQ(f->serializedItem, + serialize(boost::json::value_from(MakeFlag(i)))); + } else { + FAIL() << "expected flag to be found under " << PrefixName(i); + } + }; + + constexpr std::size_t kPrefixCount = 10; + + // Setup the same flag key (with different versions) under kPrefixCount + // prefixes. This will allow us to verify that the prefixed clients only + // "see" the single flag and not the ones living under different prefixes. + + for (std::size_t i = 0; i < kPrefixCount; i++) { + WithPrefixedClient(PrefixName(i), [&](auto const& client) { + client.PutFlag(MakeFlag(i)); + }); + } + + for (std::size_t i = 0; i < kPrefixCount; i++) { + WithPrefixedSource(PrefixName(i), [&](auto const& source) { + // Checks that the string that was stored for flag #i is the + // same one that was retrieved. + ValidateFlag(source.Get(FlagKind{}, "foo"), i); + + // Sanity check that the other flags are not visible. + auto all = source.All(FlagKind{}); + ASSERT_TRUE(all); + ASSERT_EQ(all->size(), 1); + }); + } +} + +TEST_F(RedisTests, FlagAndSegmentCanCoexistWithSameKey) { + Flag const flag_in{"foo", 1, true}; + Segment const segment_in{"foo", 1}; + + PutFlag(flag_in); + PutSegment(segment_in); + + auto flag = source->Get(FlagKind{}, "foo"); + ASSERT_TRUE(flag); + ASSERT_EQ((*flag)->serializedItem, + serialize(boost::json::value_from(flag_in))); + + auto segment = source->Get(SegmentKind{}, "foo"); + ASSERT_TRUE(segment); + ASSERT_EQ((*segment)->serializedItem, + serialize(boost::json::value_from(segment_in))); +} + +TEST(RedisErrorTests, InvalidURIs) { + std::vector const uris = {"nope, not a redis URI", + "http://foo", + "foo.com" + ""}; + + for (auto const& uri : uris) { + auto const source = RedisDataSource::Create(uri, "prefix"); + ASSERT_FALSE(source); + } +} + +TEST(RedisErrorTests, ValidURIs) { + std::vector const uris = { + "tcp://127.0.0.1:6666", + "tcp://127.0.0.1", + "tcp://pass@127.0.0.1", + "tcp://127.0.0.1:6379/2", + "tcp://127.0.0.1:6379/2?keep_alive=true", + "tcp://127.0.0.1?socket_timeout=50ms&connect_timeout=1s", + "unix://path/to/socket"}; + for (auto const& uri : uris) { + auto const source = RedisDataSource::Create(uri, "prefix"); + ASSERT_TRUE(source); + } +} + +TEST(RedisErrorTests, GetReturnsErrorAndNoExceptionThrown) { + auto const maybe_source = RedisDataSource::Create( + "tcp://foobar:1000" /* no redis service here */, "prefix"); + ASSERT_TRUE(maybe_source); + + auto const source = *maybe_source; + + auto const get_initialized = source->Initialized(); + ASSERT_FALSE(get_initialized); + + auto const get_flag = source->Get(FlagKind{}, "foo"); + ASSERT_FALSE(get_flag); + + auto const get_segment = source->Get(SegmentKind{}, "foo"); + ASSERT_FALSE(get_segment); + + auto const get_all_flag = source->All(FlagKind{}); + ASSERT_FALSE(get_all_flag); + + auto const get_all_segment = source->All(SegmentKind{}); + ASSERT_FALSE(get_all_segment); +} diff --git a/libs/server-sdk/CMakeLists.txt b/libs/server-sdk/CMakeLists.txt index 461572bab..ef1814188 100644 --- a/libs/server-sdk/CMakeLists.txt +++ b/libs/server-sdk/CMakeLists.txt @@ -24,7 +24,7 @@ endif () #set(CMAKE_FILES "${CMAKE_CURRENT_SOURCE_DIR}/cmake") #set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${CMAKE_FILES}) -# Needed to fetch external dependencies. +# Needed to fetch external dependencies. include(FetchContent) # Needed to parse RFC3339 dates in flag rules. diff --git a/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/lazy_load_builder.hpp b/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/lazy_load_builder.hpp index fbe224dd2..1a34261fa 100644 --- a/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/lazy_load_builder.hpp +++ b/libs/server-sdk/include/launchdarkly/server_side/config/builders/data_system/lazy_load_builder.hpp @@ -1,7 +1,7 @@ #pragma once #include -#include +#include #include @@ -25,7 +25,7 @@ namespace launchdarkly::server_side::config::builders { * another SDK) is necessary. */ struct LazyLoadBuilder { - using SourcePtr = std::shared_ptr; + using SourcePtr = std::shared_ptr; using EvictionPolicy = built::LazyLoadConfig::EvictionPolicy; /** * \brief Constructs a new LazyLoadBuilder. diff --git a/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/lazy_load_config.hpp b/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/lazy_load_config.hpp index 56dfdadb1..8c82ad8f9 100644 --- a/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/lazy_load_config.hpp +++ b/libs/server-sdk/include/launchdarkly/server_side/config/built/data_system/lazy_load_config.hpp @@ -1,6 +1,6 @@ #pragma once -#include +#include #include #include @@ -19,6 +19,6 @@ struct LazyLoadConfig { EvictionPolicy eviction_policy; std::chrono::milliseconds refresh_ttl; - std::shared_ptr source; + std::shared_ptr source; }; } // namespace launchdarkly::server_side::config::built diff --git a/libs/server-sdk/include/launchdarkly/server_side/data_interfaces/sources/iserialized_data_reader.hpp b/libs/server-sdk/include/launchdarkly/server_side/integrations/data_reader/iserialized_data_reader.hpp similarity index 79% rename from libs/server-sdk/include/launchdarkly/server_side/data_interfaces/sources/iserialized_data_reader.hpp rename to libs/server-sdk/include/launchdarkly/server_side/integrations/data_reader/iserialized_data_reader.hpp index 48e55361a..155fa4d11 100644 --- a/libs/server-sdk/include/launchdarkly/server_side/data_interfaces/sources/iserialized_data_reader.hpp +++ b/libs/server-sdk/include/launchdarkly/server_side/integrations/data_reader/iserialized_data_reader.hpp @@ -1,7 +1,7 @@ #pragma once -#include -#include +#include +#include #include @@ -9,7 +9,7 @@ #include #include -namespace launchdarkly::server_side::data_interfaces { +namespace launchdarkly::server_side::integrations { /** * Interface for a data reader that provides feature flags and related data in a @@ -41,11 +41,11 @@ class ISerializedDataReader { }; using GetResult = - tl::expected; + tl::expected, Error>; - using AllResult = tl::expected< - std::unordered_map, - Error>; + using AllResult = + tl::expected, + Error>; /** * Retrieves an item from the specified collection, if available. @@ -56,9 +56,8 @@ class ISerializedDataReader { * if the item did not exist, or an error. For a deleted item the serialized * item descriptor may contain a std::nullopt for the serializedItem. */ - [[nodiscard]] virtual GetResult Get( - integrations::ISerializedItemKind const& kind, - std::string const& itemKey) const = 0; + [[nodiscard]] virtual GetResult Get(ISerializedItemKind const& kind, + std::string const& itemKey) const = 0; /** * Retrieves all items from the specified collection. @@ -70,7 +69,7 @@ class ISerializedDataReader { * no items of the specified type, then return an empty collection. */ [[nodiscard]] virtual AllResult All( - integrations::ISerializedItemKind const& kind) const = 0; + ISerializedItemKind const& kind) const = 0; /** * @return Identity of the reader. Used in logs. @@ -89,4 +88,4 @@ class ISerializedDataReader { protected: ISerializedDataReader() = default; }; -} // namespace launchdarkly::server_side::data_interfaces +} // namespace launchdarkly::server_side::integrations diff --git a/libs/server-sdk/include/launchdarkly/server_side/integrations/iserialized_item_kind.hpp b/libs/server-sdk/include/launchdarkly/server_side/integrations/data_reader/iserialized_item_kind.hpp similarity index 100% rename from libs/server-sdk/include/launchdarkly/server_side/integrations/iserialized_item_kind.hpp rename to libs/server-sdk/include/launchdarkly/server_side/integrations/data_reader/iserialized_item_kind.hpp diff --git a/libs/server-sdk/src/data_components/kinds/kinds.hpp b/libs/server-sdk/include/launchdarkly/server_side/integrations/data_reader/kinds.hpp similarity index 60% rename from libs/server-sdk/src/data_components/kinds/kinds.hpp rename to libs/server-sdk/include/launchdarkly/server_side/integrations/data_reader/kinds.hpp index bb2a0d22e..fdb78bf25 100644 --- a/libs/server-sdk/src/data_components/kinds/kinds.hpp +++ b/libs/server-sdk/include/launchdarkly/server_side/integrations/data_reader/kinds.hpp @@ -1,10 +1,10 @@ #pragma once -#include +#include -namespace launchdarkly::server_side::data_components { +namespace launchdarkly::server_side::integrations { -class SegmentKind final : public integrations::ISerializedItemKind { +class SegmentKind final : public ISerializedItemKind { public: std::string const& Namespace() const override; std::uint64_t Version(std::string const& data) const override; @@ -15,7 +15,7 @@ class SegmentKind final : public integrations::ISerializedItemKind { static inline std::string const namespace_ = "segments"; }; -class FlagKind final : public integrations::ISerializedItemKind { +class FlagKind final : public ISerializedItemKind { public: std::string const& Namespace() const override; std::uint64_t Version(std::string const& data) const override; @@ -25,4 +25,4 @@ class FlagKind final : public integrations::ISerializedItemKind { private: static inline std::string const namespace_ = "features"; }; -} // namespace launchdarkly::server_side::data_components +} // namespace launchdarkly::server_side::integrations diff --git a/libs/server-sdk/include/launchdarkly/server_side/integrations/serialized_item_descriptor.hpp b/libs/server-sdk/include/launchdarkly/server_side/integrations/data_reader/serialized_item_descriptor.hpp similarity index 81% rename from libs/server-sdk/include/launchdarkly/server_side/integrations/serialized_item_descriptor.hpp rename to libs/server-sdk/include/launchdarkly/server_side/integrations/data_reader/serialized_item_descriptor.hpp index 71f48b986..87ffc3211 100644 --- a/libs/server-sdk/include/launchdarkly/server_side/integrations/serialized_item_descriptor.hpp +++ b/libs/server-sdk/include/launchdarkly/server_side/integrations/data_reader/serialized_item_descriptor.hpp @@ -20,11 +20,7 @@ struct SerializedItemDescriptor { */ bool deleted; - /** - * When reading from a persistent store the serializedItem may be - * std::nullopt for deleted items. - */ - std::optional serializedItem; + std::string serializedItem; /** * @brief Constructs a SerializedItemDescriptor from a version and a @@ -33,7 +29,7 @@ struct SerializedItemDescriptor { * @param data Serialized item. * @return SerializedItemDescriptor. */ - static SerializedItemDescriptor Present(std::uint64_t version, + static SerializedItemDescriptor Present(std::uint64_t const version, std::string data) { return SerializedItemDescriptor{version, false, std::move(data)}; } @@ -50,8 +46,8 @@ struct SerializedItemDescriptor { * @param tombstone_rep Serialized tombstone representation of the item. * @return SerializedItemDescriptor. */ - static SerializedItemDescriptor Absent(std::uint64_t const version, - std::string tombstone_rep) { + static SerializedItemDescriptor Tombstone(std::uint64_t const version, + std::string tombstone_rep) { return SerializedItemDescriptor{version, true, std::move(tombstone_rep)}; } @@ -65,7 +61,7 @@ inline bool operator==(SerializedItemDescriptor const& lhs, inline void PrintTo(SerializedItemDescriptor const& item, std::ostream* os) { *os << "{version=" << item.version << ", deleted=" << item.deleted - << ", item=" << item.serializedItem.value_or("nullopt") << "}"; + << ", item=" << item.serializedItem << "}"; } } // namespace launchdarkly::server_side::integrations diff --git a/libs/server-sdk/src/CMakeLists.txt b/libs/server-sdk/src/CMakeLists.txt index 908b87f28..825855f2d 100644 --- a/libs/server-sdk/src/CMakeLists.txt +++ b/libs/server-sdk/src/CMakeLists.txt @@ -29,6 +29,7 @@ target_sources(${LIBNAME} all_flags_state/all_flags_state.cpp all_flags_state/json_all_flags_state.cpp all_flags_state/all_flags_state_builder.cpp + integrations/data_reader/kinds.cpp data_components/change_notifier/change_notifier.hpp data_components/change_notifier/change_notifier.cpp data_components/dependency_tracker/dependency_tracker.hpp @@ -41,8 +42,6 @@ target_sources(${LIBNAME} data_components/serialization_adapters/json_deserializer.cpp data_components/serialization_adapters/json_destination.hpp data_components/serialization_adapters/json_destination.cpp - data_components/kinds/kinds.hpp - data_components/kinds/kinds.cpp data_systems/background_sync/sources/polling/polling_data_source.hpp data_systems/background_sync/sources/polling/polling_data_source.cpp data_systems/background_sync/sources/streaming/streaming_data_source.hpp diff --git a/libs/server-sdk/src/data_components/serialization_adapters/json_deserializer.cpp b/libs/server-sdk/src/data_components/serialization_adapters/json_deserializer.cpp index 11f06305a..5f646bae9 100644 --- a/libs/server-sdk/src/data_components/serialization_adapters/json_deserializer.cpp +++ b/libs/server-sdk/src/data_components/serialization_adapters/json_deserializer.cpp @@ -3,7 +3,7 @@ #include #include -#include +#include #include @@ -11,29 +11,29 @@ namespace launchdarkly::server_side::data_components { JsonDeserializer::JsonDeserializer( Logger const& logger, - std::shared_ptr reader) + std::shared_ptr reader) : logger_(logger), flag_kind_(), segment_kind_(), source_(std::move(reader)), identity_(source_->Identity() + " (JSON)") {} -data_interfaces::IDataReader::Single +data_interfaces::IDataReader::SingleResult JsonDeserializer::GetFlag(std::string const& key) const { return DeserializeSingle(flag_kind_, key); } -data_interfaces::IDataReader::Single +data_interfaces::IDataReader::SingleResult JsonDeserializer::GetSegment(std::string const& key) const { return DeserializeSingle(segment_kind_, key); } -data_interfaces::IDataReader::Collection +data_interfaces::IDataReader::CollectionResult JsonDeserializer::AllFlags() const { return DeserializeCollection(flag_kind_); } -data_interfaces::IDataReader::Collection +data_interfaces::IDataReader::CollectionResult JsonDeserializer::AllSegments() const { return DeserializeCollection(segment_kind_); } diff --git a/libs/server-sdk/src/data_components/serialization_adapters/json_deserializer.hpp b/libs/server-sdk/src/data_components/serialization_adapters/json_deserializer.hpp index 67dedcc49..906eac51c 100644 --- a/libs/server-sdk/src/data_components/serialization_adapters/json_deserializer.hpp +++ b/libs/server-sdk/src/data_components/serialization_adapters/json_deserializer.hpp @@ -1,31 +1,87 @@ #pragma once +#include "../../../include/launchdarkly/server_side/integrations/data_reader/kinds.hpp" #include "../../data_interfaces/source/idata_reader.hpp" -#include "../kinds/kinds.hpp" #include -#include +#include #include namespace launchdarkly::server_side::data_components { +template +tl::expected, + data_interfaces::IDataReader::Error> +IntoStorageItem(integrations::SerializedItemDescriptor const& descriptor) { + if (descriptor.deleted) { + return data_interfaces::IDataReader::StorageItem( + data_interfaces::IDataReader::Tombstone(descriptor.version)); + } + + auto const json_val = boost::json::parse(descriptor.serializedItem); + + auto result = + boost::json::value_to, JsonError>>( + json_val); + + if (!result) { + /* maybe it's a tombstone - check */ + /* TODO(225976): replace with boost::json deserializer */ + if (json_val.is_object()) { + auto const& obj = json_val.as_object(); + if (auto deleted_it = obj.find("deleted"); + deleted_it != obj.end()) { + auto const& deleted = deleted_it->value(); + + if (deleted.is_bool() && deleted.as_bool()) { + if (auto version_it = obj.find("version"); + version_it != obj.end()) { + auto const& version = version_it->value(); + if (version.is_number()) { + return data_interfaces::IDataReader::Tombstone( + version.as_uint64()); + } + return tl::make_unexpected( + data_interfaces::IDataReader::Error{ + "tombstone field 'version' is invalid"}); + } + return tl::make_unexpected( + "tombstone field 'version' is missing"); + } + return tl::make_unexpected( + "tombstone field 'deleted' is invalid "); + } + } + + return tl::make_unexpected( + "serialized item isn't a valid data item or tombstone"); + } + + auto item = *result; + + if (!item) { + return tl::make_unexpected("serialized item is null JSON value"); + } + + return *item; +} + class JsonDeserializer final : public data_interfaces::IDataReader { public: explicit JsonDeserializer( Logger const& logger, - std::shared_ptr reader); + std::shared_ptr reader); - [[nodiscard]] Single GetFlag( + [[nodiscard]] SingleResult GetFlag( std::string const& key) const override; - [[nodiscard]] Single GetSegment( + [[nodiscard]] SingleResult GetSegment( std::string const& key) const override; - [[nodiscard]] Collection AllFlags() - const override; + [[nodiscard]] CollectionResult AllFlags() const override; - [[nodiscard]] Collection AllSegments() + [[nodiscard]] CollectionResult AllSegments() const override; [[nodiscard]] std::string const& Identity() const override; @@ -34,29 +90,8 @@ class JsonDeserializer final : public data_interfaces::IDataReader { private: template - tl::expected, std::string> - DeserializeItem(std::string const& serialized_item) const { - auto const boost_json_val = boost::json::parse(serialized_item); - auto item = boost::json::value_to< - tl::expected, JsonError>>(boost_json_val); - - if (!item) { - return tl::make_unexpected(ErrorToString(item.error())); - } - - std::optional maybe_item = item->value(); - - if (!maybe_item) { - return tl::make_unexpected("JSON value is null"); - } - - return data_model::ItemDescriptor(std::move(*maybe_item)); - } - - template - Single> DeserializeSingle( - DataKind const& kind, - std::string const& key) const { + SingleResult DeserializeSingle(DataKind const& kind, + std::string const& key) const { auto result = source_->Get(kind, key); if (!result) { @@ -64,16 +99,17 @@ class JsonDeserializer final : public data_interfaces::IDataReader { return tl::make_unexpected(result.error().message); } - if (!result->serializedItem) { - /* no error, but item not found by the source */ + auto serialized_item = *result; + + if (!serialized_item) { return std::nullopt; } - return DeserializeItem(*result->serializedItem); + return IntoStorageItem(*serialized_item); } template - Collection> DeserializeCollection( + CollectionResult DeserializeCollection( DataKind const& kind) const { auto result = source_->All(kind); @@ -82,39 +118,27 @@ class JsonDeserializer final : public data_interfaces::IDataReader { return tl::make_unexpected(result.error().message); } - std::unordered_map> - items; + Collection items; for (auto const& [key, descriptor] : *result) { - if (!descriptor.serializedItem) { - /* item is deleted, add a tombstone to the result so that the - * caller can make a decision on what to do. */ - items.emplace(key, data_model::ItemDescriptor( - descriptor.version)); - continue; - } - - auto maybe_item = DeserializeItem( - *descriptor.serializedItem); + auto item = IntoStorageItem(descriptor); - if (!maybe_item) { - /* single item failing to deserialize doesn't cause the - * whole operation to fail; other items may be valid. */ + if (!item) { LD_LOG(logger_, LogLevel::kError) << "failed to deserialize " << key << " while fetching all " - << kind.Namespace() << ": " << maybe_item.error(); + << kind.Namespace() << ": " << item.error(); continue; } - items.emplace(key, *maybe_item); + items.emplace(key, *item); } return items; } Logger const& logger_; - FlagKind const flag_kind_; - FlagKind const segment_kind_; - std::shared_ptr source_; + integrations::FlagKind const flag_kind_; + integrations::SegmentKind const segment_kind_; + std::shared_ptr source_; std::string const identity_; }; diff --git a/libs/server-sdk/src/data_components/serialization_adapters/json_destination.cpp b/libs/server-sdk/src/data_components/serialization_adapters/json_destination.cpp index 52aa626d4..b85f2f978 100644 --- a/libs/server-sdk/src/data_components/serialization_adapters/json_destination.cpp +++ b/libs/server-sdk/src/data_components/serialization_adapters/json_destination.cpp @@ -3,13 +3,18 @@ #include #include +#include + namespace launchdarkly::server_side::data_components { using data_interfaces::ISerializedDestination; using integrations::SerializedItemDescriptor; -FlagKind const JsonDestination::Kinds::Flag = FlagKind(); -SegmentKind const JsonDestination::Kinds::Segment = SegmentKind(); +integrations::FlagKind const JsonDestination::Kinds::Flag = + integrations::FlagKind(); + +integrations::SegmentKind const JsonDestination::Kinds::Segment = + integrations::SegmentKind(); /** * @brief Creates a boost::json::value representing a tombstone for a given @@ -49,7 +54,7 @@ SerializedItemDescriptor Serialize(std::string const& key, ? SerializedItemDescriptor::Present( desc.version, boost::json::serialize( boost::json::value_from(*desc.item))) - : SerializedItemDescriptor::Absent( + : SerializedItemDescriptor::Tombstone( desc.version, boost::json::serialize(Tombstone(key, desc))); } diff --git a/libs/server-sdk/src/data_components/serialization_adapters/json_destination.hpp b/libs/server-sdk/src/data_components/serialization_adapters/json_destination.hpp index 37c4d7289..77f4e6a01 100644 --- a/libs/server-sdk/src/data_components/serialization_adapters/json_destination.hpp +++ b/libs/server-sdk/src/data_components/serialization_adapters/json_destination.hpp @@ -1,6 +1,6 @@ #pragma once -#include "../../data_components/kinds/kinds.hpp" +#include "../../../include/launchdarkly/server_side/integrations/data_reader/kinds.hpp" #include "../../data_interfaces/destination/idestination.hpp" #include "../../data_interfaces/destination/iserialized_destination.hpp" @@ -79,8 +79,8 @@ class JsonDestination final : public data_interfaces::IDestination { * @brief These are public so they can be referenced in tests. */ struct Kinds { - static FlagKind const Flag; - static SegmentKind const Segment; + static integrations::FlagKind const Flag; + static integrations::SegmentKind const Segment; }; private: diff --git a/libs/server-sdk/src/data_interfaces.cpp b/libs/server-sdk/src/data_interfaces.cpp index 030e9b2c5..286ba8a80 100644 --- a/libs/server-sdk/src/data_interfaces.cpp +++ b/libs/server-sdk/src/data_interfaces.cpp @@ -5,5 +5,5 @@ #include "data_interfaces/store/istore.hpp" #include "data_interfaces/system/idata_system.hpp" -#include -#include +#include +#include diff --git a/libs/server-sdk/src/data_interfaces/destination/iserialized_destination.hpp b/libs/server-sdk/src/data_interfaces/destination/iserialized_destination.hpp index af99c100a..7d19c24f4 100644 --- a/libs/server-sdk/src/data_interfaces/destination/iserialized_destination.hpp +++ b/libs/server-sdk/src/data_interfaces/destination/iserialized_destination.hpp @@ -1,7 +1,7 @@ #pragma once -#include -#include +#include +#include #include #include diff --git a/libs/server-sdk/src/data_interfaces/source/idata_reader.hpp b/libs/server-sdk/src/data_interfaces/source/idata_reader.hpp index adcc7e38f..e2c46389e 100644 --- a/libs/server-sdk/src/data_interfaces/source/idata_reader.hpp +++ b/libs/server-sdk/src/data_interfaces/source/idata_reader.hpp @@ -20,13 +20,33 @@ namespace launchdarkly::server_side::data_interfaces { */ class IDataReader { public: + BOOST_STRONG_TYPEDEF(std::uint64_t, Tombstone); + + template + using StorageItem = std::variant; + + template + static data_model::ItemDescriptor StorageItemIntoDescriptor( + StorageItem item) { + if (std::holds_alternative(item)) { + return data_model::ItemDescriptor(std::get(item)); + } + return data_model::ItemDescriptor(std::move(std::get(item))); + } + using Error = std::string; template - using Single = tl::expected, Error>; + using Single = std::optional>; template - using Collection = tl::expected, Error>; + using SingleResult = tl::expected, Error>; + + template + using Collection = std::unordered_map>; + + template + using CollectionResult = tl::expected, Error>; /** * @brief Attempts to get a flag named by key. @@ -34,7 +54,7 @@ class IDataReader { * @return On success, an optional FlagDescriptor (std::nullopt means the * flag doesn't exist.) On failure, an error string. */ - [[nodiscard]] virtual Single GetFlag( + [[nodiscard]] virtual SingleResult GetFlag( std::string const& key) const = 0; /** @@ -43,7 +63,7 @@ class IDataReader { * @return On success, an optional SegmentDescriptor (std::nullopt means the * segment doesn't exist.) On failure, an error string. */ - [[nodiscard]] virtual Single GetSegment( + [[nodiscard]] virtual SingleResult GetSegment( std::string const& key) const = 0; /** @@ -51,7 +71,7 @@ class IDataReader { * @return On success, a collection of FlagDescriptors. On failure, an error * string. */ - [[nodiscard]] virtual Collection AllFlags() + [[nodiscard]] virtual CollectionResult AllFlags() const = 0; /** @@ -59,8 +79,8 @@ class IDataReader { * @return On success, a collection of SegmentDescriptors. On failure, an * error string. */ - [[nodiscard]] virtual Collection - AllSegments() const = 0; + [[nodiscard]] virtual CollectionResult AllSegments() + const = 0; /** * @return Identity of the reader. Used in logs. diff --git a/libs/server-sdk/src/data_systems/background_sync/sources/streaming/streaming_data_source.cpp b/libs/server-sdk/src/data_systems/background_sync/sources/streaming/streaming_data_source.cpp index 3828ae5d7..923939774 100644 --- a/libs/server-sdk/src/data_systems/background_sync/sources/streaming/streaming_data_source.cpp +++ b/libs/server-sdk/src/data_systems/background_sync/sources/streaming/streaming_data_source.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include 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 26c246787..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 @@ -48,11 +48,10 @@ namespace launchdarkly::server_side::data_systems { -data_components::FlagKind const LazyLoad::Kinds::Flag = - data_components::FlagKind(); +integrations::FlagKind const LazyLoad::Kinds::Flag = integrations::FlagKind(); -data_components::SegmentKind const LazyLoad::Kinds::Segment = - data_components::SegmentKind(); +integrations::SegmentKind const LazyLoad::Kinds::Segment = + integrations::SegmentKind(); LazyLoad::LazyLoad(Logger const& logger, config::built::LazyLoadConfig cfg, @@ -90,7 +89,7 @@ std::shared_ptr LazyLoad::GetFlag( auto const state = tracker_.State(data_components::DataKind::kFlag, key, time_()); return Get>( - state, [this, &key]() { RefreshFlag(key); }, + key, state, [this, &key]() { RefreshFlag(key); }, [this, &key]() { return cache_.GetFlag(key); }); } @@ -99,7 +98,7 @@ std::shared_ptr LazyLoad::GetSegment( auto const state = tracker_.State(data_components::DataKind::kSegment, key, time_()); return Get>( - state, [this, &key]() { RefreshSegment(key); }, + key, state, [this, &key]() { RefreshSegment(key); }, [this, &key]() { return cache_.GetSegment(key); }); } @@ -108,7 +107,7 @@ LazyLoad::AllFlags() const { auto const state = tracker_.State(Keys::kAllFlags, time_()); return Get>>( - state, [this]() { RefreshAllFlags(); }, + Keys::kAllFlags, state, [this]() { RefreshAllFlags(); }, [this]() { return cache_.AllFlags(); }); } @@ -117,7 +116,7 @@ LazyLoad::AllSegments() const { auto const state = tracker_.State(Keys::kAllSegments, time_()); return Get>>( - state, [this]() { RefreshAllSegments(); }, + Keys::kAllSegments, state, [this]() { RefreshAllSegments(); }, [this]() { return cache_.AllSegments(); }); } @@ -144,13 +143,15 @@ bool LazyLoad::Initialized() const { } void LazyLoad::RefreshAllFlags() const { - RefreshAll(Keys::kAllFlags, data_components::DataKind::kFlag, - [this]() { return reader_->AllFlags(); }); + RefreshAll(Keys::kAllFlags, + data_components::DataKind::kFlag, + [this]() { return reader_->AllFlags(); }); } void LazyLoad::RefreshAllSegments() const { - RefreshAll(Keys::kAllSegments, data_components::DataKind::kSegment, - [this]() { return reader_->AllSegments(); }); + RefreshAll( + Keys::kAllSegments, data_components::DataKind::kSegment, + [this]() { return reader_->AllSegments(); }); } void LazyLoad::RefreshInitState() const { @@ -159,14 +160,14 @@ void LazyLoad::RefreshInitState() const { } void LazyLoad::RefreshSegment(std::string const& segment_key) const { - RefreshItem( + RefreshItem( data_components::DataKind::kSegment, segment_key, [this](std::string const& key) { return reader_->GetSegment(key); }, [this](std::string const& key) { return cache_.RemoveSegment(key); }); } void LazyLoad::RefreshFlag(std::string const& flag_key) const { - RefreshItem( + RefreshItem( data_components::DataKind::kFlag, flag_key, [this](std::string const& key) { return reader_->GetFlag(key); }, [this](std::string const& key) { return cache_.RemoveFlag(key); }); @@ -179,4 +180,17 @@ std::chrono::time_point LazyLoad::ExpiryTime() fresh_duration_); } +std::string LazyLoad::CacheTraceMsg( + data_components::ExpirationTracker::TrackState const state) { + switch (state) { + case data_components::ExpirationTracker::TrackState::kStale: + return "cache hit (stale)"; + case data_components::ExpirationTracker::TrackState::kNotTracked: + return "cache miss"; + case data_components::ExpirationTracker::TrackState::kFresh: + return "cache hit"; + } + detail::unreachable(); +} + } // namespace launchdarkly::server_side::data_systems 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 09b5130d5..bc8c5c2d3 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,14 +1,14 @@ #pragma once +#include "../../../include/launchdarkly/server_side/integrations/data_reader/kinds.hpp" #include "../../data_components/expiration_tracker/expiration_tracker.hpp" -#include "../../data_components/kinds/kinds.hpp" #include "../../data_components/memory_store/memory_store.hpp" #include "../../data_components/status_notifications/data_source_status_manager.hpp" #include "../../data_interfaces/source/idata_reader.hpp" #include "../../data_interfaces/system/idata_system.hpp" #include -#include +#include #include #include @@ -60,8 +60,8 @@ class LazyLoad final : public data_interfaces::IDataSystem { // Public for usage in tests. struct Kinds { - static data_components::FlagKind const Flag; - static data_components::SegmentKind const Segment; + static integrations::FlagKind const Flag; + static integrations::SegmentKind const Segment; }; private: @@ -71,10 +71,18 @@ class LazyLoad final : public data_interfaces::IDataSystem { void RefreshFlag(std::string const& key) const; void RefreshSegment(std::string const& key) const; + static std::string CacheTraceMsg( + data_components::ExpirationTracker::TrackState state); + template - static TResult Get(data_components::ExpirationTracker::TrackState state, - std::function const& refresh, - std::function const& get) { + TResult Get(std::string const& key, + data_components::ExpirationTracker::TrackState const state, + + std::function const& refresh, + std::function const& get) const { + LD_LOG(logger_, LogLevel::kDebug) + << Identity() << ": get " << key << " - " << CacheTraceMsg(state); + switch (state) { case data_components::ExpirationTracker::TrackState::kStale: [[fallthrough]]; @@ -87,11 +95,13 @@ class LazyLoad final : public data_interfaces::IDataSystem { detail::unreachable(); } - template - void RefreshItem(data_components::DataKind const kind, - std::string const& key, - Getter&& getter, - Evictor&& evictor) const { + template + void RefreshItem( + data_components::DataKind const kind, + std::string const& key, + std::function( + std::string const&)> const& getter, + Evictor&& evictor) const { // Refreshing this item is always rate limited, even // if the refresh has an error. tracker_.Add(kind, key, ExpiryTime()); @@ -100,7 +110,15 @@ class LazyLoad final : public data_interfaces::IDataSystem { status_manager_.SetState(DataSourceState::kValid); if (auto optional_item = *expected_item) { - cache_.Upsert(key, std::move(*optional_item)); + // This transformation is necessary because the memory store + // works with ItemDescriptors, whereas the reader operates using + // IDataReader::StorageItems. This doesn't necessarily need to + // be the case. + cache_.Upsert( + key, + data_interfaces::IDataReader::StorageItemIntoDescriptor( + std::move(*optional_item))); + } else { // If the item is actually *missing* - not just a deleted // tombstone representation - it implies that the source @@ -128,10 +146,13 @@ class LazyLoad final : public data_interfaces::IDataSystem { } } - template - void RefreshAll(std::string const& all_item_key, - data_components::DataKind const item_kind, - Getter&& getter) const { + template + void RefreshAll( + std::string const& all_item_key, + data_components::DataKind const item_kind, + std::function< + data_interfaces::IDataReader::CollectionResult()> const& + getter) const { // Storing an expiry time so that the 'all' key and the individual // item keys will expire at the same time. auto const updated_expiry = ExpiryTime(); @@ -144,8 +165,15 @@ class LazyLoad final : public data_interfaces::IDataSystem { status_manager_.SetState(DataSourceState::kValid); for (auto item : *all_items) { - cache_.Upsert(item.first, std::move(item.second)); - tracker_.Add(item_kind, item.first, updated_expiry); + // This transformation is necessary because the memory store + // works with ItemDescriptors, whereas the reader operates using + // IDataReader::StorageItems. This doesn't necessarily need to + // be the case. + cache_.Upsert( + item.first, + data_interfaces::IDataReader::StorageItemIntoDescriptor( + std::move(item.second))); + tracker_.Add(item.first, updated_expiry); } } else { status_manager_.SetState( diff --git a/libs/server-sdk/src/data_components/kinds/kinds.cpp b/libs/server-sdk/src/integrations/data_reader/kinds.cpp similarity index 85% rename from libs/server-sdk/src/data_components/kinds/kinds.cpp rename to libs/server-sdk/src/integrations/data_reader/kinds.cpp index eaaea2c35..3c5f7d8b6 100644 --- a/libs/server-sdk/src/data_components/kinds/kinds.cpp +++ b/libs/server-sdk/src/integrations/data_reader/kinds.cpp @@ -1,4 +1,4 @@ -#include "kinds.hpp" +#include #include #include @@ -6,7 +6,7 @@ #include -namespace launchdarkly::server_side::data_components { +namespace launchdarkly::server_side::integrations { template static uint64_t GetVersion(std::string const& data) { @@ -42,4 +42,4 @@ std::uint64_t FlagKind::Version(std::string const& data) const { return GetVersion(data); } -} // namespace launchdarkly::server_side::data_components +} // namespace launchdarkly::server_side::integrations diff --git a/libs/server-sdk/tests/CMakeLists.txt b/libs/server-sdk/tests/CMakeLists.txt index cadf72f23..837735a10 100644 --- a/libs/server-sdk/tests/CMakeLists.txt +++ b/libs/server-sdk/tests/CMakeLists.txt @@ -9,8 +9,10 @@ file(GLOB tests "${PROJECT_SOURCE_DIR}/tests/*.cpp") set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}) # Get things in the same directory on windows. -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}../") -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}../") +if (WIN32) + set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}../") + set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}../") +endif () add_executable(gtest_${LIBNAME} ${tests} diff --git a/libs/server-sdk/tests/json_destination_test.cpp b/libs/server-sdk/tests/json_destination_test.cpp index 1eadae8d8..6cd0ba54f 100644 --- a/libs/server-sdk/tests/json_destination_test.cpp +++ b/libs/server-sdk/tests/json_destination_test.cpp @@ -149,7 +149,7 @@ TEST_F(JsonDestinationTest, UpsertDeletedFlagCreatesTombstone) { EXPECT_CALL( mock_dest, Upsert(Ref(JsonDestination::Kinds::Flag), "flag", - SerializedItemDescriptor::Absent( + SerializedItemDescriptor::Tombstone( 2, "{\"key\":\"flag\",\"version\":2,\"deleted\":true}"))) .WillOnce(Return(ISerializedDestination::UpsertResult::kSuccess)); @@ -160,7 +160,7 @@ TEST_F(JsonDestinationTest, UpsertDeletedSegmentCreatesTombstone) { EXPECT_CALL( mock_dest, Upsert(Ref(JsonDestination::Kinds::Segment), "segment", - SerializedItemDescriptor::Absent( + SerializedItemDescriptor::Tombstone( 2, "{\"key\":\"segment\",\"version\":2,\"deleted\":true}"))) .WillOnce(Return(ISerializedDestination::UpsertResult::kSuccess)); diff --git a/libs/server-sdk/tests/lazy_load_system_test.cpp b/libs/server-sdk/tests/lazy_load_system_test.cpp index cb42ed9d3..f908a8ac8 100644 --- a/libs/server-sdk/tests/lazy_load_system_test.cpp +++ b/libs/server-sdk/tests/lazy_load_system_test.cpp @@ -17,7 +17,7 @@ using ::testing::InSequence; using ::testing::NiceMock; using ::testing::Return; -class MockDataReader : public data_interfaces::ISerializedDataReader { +class MockDataReader : public integrations::ISerializedDataReader { public: MOCK_METHOD(GetResult, Get, @@ -104,14 +104,14 @@ TEST_F(LazyLoadTest, ReaderIsNotQueriedRepeatedlyIfFlagCannotBeFetched) { EXPECT_CALL(*mock_reader, Get(testing::_, "foo")) .WillOnce(Return(tl::make_unexpected( - data_interfaces::ISerializedDataReader::Error{"oops"}))); + integrations::ISerializedDataReader::Error{"oops"}))); for (std::size_t i = 0; i < 20; i++) { ASSERT_FALSE(lazy_load.GetFlag("foo")); }; - ASSERT_TRUE(spy_logger_backend->Count(1)); - ASSERT_TRUE(spy_logger_backend->Contains(0, LogLevel::kError, "oops")); + ASSERT_TRUE(spy_logger_backend->Count(21)); // 20 debug logs + 1 error log + ASSERT_TRUE(spy_logger_backend->Contains(1, LogLevel::kError, "oops")); } TEST_F(LazyLoadTest, ReaderIsNotQueriedRepeatedlyIfSegmentCannotBeFetched) { @@ -123,14 +123,14 @@ TEST_F(LazyLoadTest, ReaderIsNotQueriedRepeatedlyIfSegmentCannotBeFetched) { EXPECT_CALL(*mock_reader, Get(testing::_, "foo")) .WillOnce(Return(tl::make_unexpected( - data_interfaces::ISerializedDataReader::Error{"oops"}))); + integrations::ISerializedDataReader::Error{"oops"}))); for (std::size_t i = 0; i < 20; i++) { ASSERT_FALSE(lazy_load.GetSegment("foo")); }; - ASSERT_TRUE(spy_logger_backend->Count(1)); - ASSERT_TRUE(spy_logger_backend->Contains(0, LogLevel::kError, "oops")); + ASSERT_TRUE(spy_logger_backend->Count(21)); + ASSERT_TRUE(spy_logger_backend->Contains(1, LogLevel::kError, "oops")); } TEST_F(LazyLoadTest, RefreshesFlagIfStale) { diff --git a/libs/server-sent-events/tests/CMakeLists.txt b/libs/server-sent-events/tests/CMakeLists.txt index 03209aa80..eb20d404f 100644 --- a/libs/server-sent-events/tests/CMakeLists.txt +++ b/libs/server-sent-events/tests/CMakeLists.txt @@ -9,8 +9,10 @@ file(GLOB tests "${PROJECT_SOURCE_DIR}/tests/*.cpp") set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}) # Get things in the same directory on windows. -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}../") -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}../") +if (WIN32) + set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}../") + set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}../") +endif () add_executable(gtest_${LIBNAME} ${tests}) diff --git a/scripts/build-release.sh b/scripts/build-release.sh index c06d26824..7d24f754e 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -5,10 +5,18 @@ set -e + +# Special case: unlike the other targets, enabling redis support will pull in redis++ and hiredis dependencies at +# configuration time. To ensure this only happens when asked, disable the support by default. +build_redis="OFF" +if [ "$1" == "launchdarkly-cpp-server-redis-source" ]; then + build_redis="ON" +fi + # Build a static release. mkdir -p build-static && cd build-static mkdir -p release -cmake -G Ninja -D CMAKE_BUILD_TYPE=Release -D BUILD_TESTING=OFF -D CMAKE_INSTALL_PREFIX=./release .. +cmake -G Ninja -D CMAKE_BUILD_TYPE=Release -D LD_BUILD_REDIS_SUPPORT="$build_redis" -D BUILD_TESTING=OFF -D CMAKE_INSTALL_PREFIX=./release .. cmake --build . --target "$1" cmake --install . @@ -17,7 +25,7 @@ cd .. # Build a dynamic release. mkdir -p build-dynamic && cd build-dynamic mkdir -p release -cmake -G Ninja -D CMAKE_BUILD_TYPE=Release -D BUILD_TESTING=OFF -D LD_BUILD_SHARED_LIBS=ON -D CMAKE_INSTALL_PREFIX=./release .. +cmake -G Ninja -D CMAKE_BUILD_TYPE=Release -D LD_BUILD_REDIS_SUPPORT="$build_redis" -D BUILD_TESTING=OFF -D LD_BUILD_SHARED_LIBS=ON -D CMAKE_INSTALL_PREFIX=./release .. cmake --build . --target "$1" cmake --install . diff --git a/scripts/build.sh b/scripts/build.sh index 7b77203f0..27967a1ad 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -17,6 +17,19 @@ cd build # script ends. trap cleanup EXIT -cmake -G Ninja -D CMAKE_COMPILE_WARNING_AS_ERROR=TRUE -D BUILD_TESTING="$2" -D LD_BUILD_UNIT_TESTS="$2" -D LD_BUILD_CONTRACT_TESTS="$2" .. +# Special case: unlike the other targets, enabling redis support will pull in redis++ and hiredis dependencies at +# configuration time. To ensure this only happens when asked, disable the support by default. +build_redis="OFF" +if [ "$1" == "launchdarkly-cpp-server-redis-source" ] || [ "$1" == "gtest_launchdarkly-cpp-server-redis-source" ]; then + build_redis="ON" +fi + + + +cmake -G Ninja -D CMAKE_COMPILE_WARNING_AS_ERROR=TRUE \ + -D BUILD_TESTING="$2" \ + -D LD_BUILD_UNIT_TESTS="$2" \ + -D LD_BUILD_CONTRACT_TESTS="$2" \ + -D LD_BUILD_REDIS_SUPPORT="$build_redis" .. cmake --build . --target "$1" diff --git a/scripts/run-hello-apps.sh b/scripts/run-hello-apps.sh index 9b87fed5d..e24412a75 100755 --- a/scripts/run-hello-apps.sh +++ b/scripts/run-hello-apps.sh @@ -41,5 +41,5 @@ for target in "$@" do cmake --build . --target "$target" ./examples/"$target"/"$target" | tee "$target"_output.txt - grep "failed to initialize" "$target"_output.txt || (echo "$target: expected connection to LD to fail" && exit 1) + grep -F "*** SDK" "$target"_output.txt || (echo "$target: expected connection to LD to fail" && exit 1) done