Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions libs/client-sdk/src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ target_sources(${LIBNAME} PRIVATE
data_sources/fdv2/polling_synchronizer.cpp
data_sources/fdv2/streaming_synchronizer.cpp
data_sources/fdv2/fdv2_data_source.cpp
data_sources/fdv2/cache_initializer.cpp
data_sources/data_source_event_handler.cpp
data_sources/polling_data_source.cpp
flag_manager/flag_store.cpp
Expand Down Expand Up @@ -49,6 +50,7 @@ target_sources(${LIBNAME} PRIVATE
data_sources/fdv2/polling_synchronizer.hpp
data_sources/fdv2/streaming_synchronizer.hpp
data_sources/fdv2/fdv2_data_source.hpp
data_sources/fdv2/cache_initializer.hpp
flag_manager/flag_store.hpp
flag_manager/flag_updater.hpp
bindings/c/sdk.cpp
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ class IDataSourceUpdateSink {
* instead.
*
* @param from_cache Whether the changeset was loaded from the local
* cache, in which case it is not written back to it.
* cache. Cached data is not written back to the cache, and does not
* count as the service confirming the cache is current.
*/
virtual void Apply(Context const& context,
FlagChangeSet change_set,
Expand Down
61 changes: 61 additions & 0 deletions libs/client-sdk/src/data_sources/fdv2/cache_initializer.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#include "cache_initializer.hpp"

#include <utility>

namespace launchdarkly::client_side::data_sources {

static char const* const kIdentity = "FDv2 cache initializer";

FDv2CacheInitializer::FDv2CacheInitializer(flag_manager::FlagPersistence* cache,
Context context,
Logger const& logger)
: cache_(cache), context_(std::move(context)), logger_(logger) {}

async::Future<FDv2SourceResult> FDv2CacheInitializer::Run() {
auto data = cache_->ReadCached(context_);
if (!data) {
LD_LOG(logger_, LogLevel::kDebug)
<< kIdentity << ": no cached data for this context";
// A miss leaves the data set unchanged and lets initialization
// continue, which is what a "none" intent means.
return async::MakeFuture(FDv2SourceResult{FDv2SourceResult::ChangeSet{
FlagChangeSet{data_model::ChangeSetType::kNone,
{},
data_model::Selector{}}}});
}

LD_LOG(logger_, LogLevel::kDebug)
<< kIdentity << ": loaded " << data->size()
<< " flags for this context";

FlagChangeSetData changes;
changes.reserve(data->size());
for (auto& [key, item] : *data) {
changes.push_back(FlagChange{key, std::move(item)});
}

return async::MakeFuture(FDv2SourceResult{FDv2SourceResult::ChangeSet{
FlagChangeSet{data_model::ChangeSetType::kFull, std::move(changes),
data_model::Selector{}}}});
}

void FDv2CacheInitializer::Close() {
// Run() completes on the calling thread, so there is nothing to cancel.
}

std::string const& FDv2CacheInitializer::Identity() const {
static std::string const identity = kIdentity;
return identity;
}

FDv2CacheInitializerFactory::FDv2CacheInitializerFactory(
flag_manager::FlagPersistence* cache,
Context context,
Logger const& logger)
: cache_(cache), context_(std::move(context)), logger_(logger) {}

std::unique_ptr<IFDv2Initializer> FDv2CacheInitializerFactory::Build() {
return std::make_unique<FDv2CacheInitializer>(cache_, context_, logger_);
}

} // namespace launchdarkly::client_side::data_sources
80 changes: 80 additions & 0 deletions libs/client-sdk/src/data_sources/fdv2/cache_initializer.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#pragma once

#include "ifdv2_initializer.hpp"
#include "ifdv2_initializer_factory.hpp"

#include "../../flag_manager/flag_persistence.hpp"

#include <launchdarkly/async/promise.hpp>
#include <launchdarkly/context.hpp>
#include <launchdarkly/logging/logger.hpp>

#include <string>

namespace launchdarkly::client_side::data_sources {

/**
* Loads flag data the SDK persisted for this context on a previous run, so
* that evaluation can begin before the network answers.
*
* The cache is read on the calling thread. The read is fast enough that
* dispatching it to the executor would cost more than it saves.
*
* Cached data never carries a selector. A selector names a state the service
* can compute changes against, and the SDK does not verify that persisted
* data is intact. Asking for a delta against data that may not be what the
* service thinks it is would corrupt the store silently. Initialization
* therefore continues past the cache to a network source, which supplies both
* data and a selector.
*
* Run() reads the cache on the calling thread and returns a future that is
* already resolved. Close() may be called from any thread and has nothing to
* cancel.
*/
class FDv2CacheInitializer final : public IFDv2Initializer {
public:
/**
* @param cache The local cache to read. Non-owning. Must outlive this
* object.
* @param context The evaluation context to load data for.
* @param logger Receives diagnostic logging.
*/
FDv2CacheInitializer(flag_manager::FlagPersistence* cache,
Context context,
Logger const& logger);

async::Future<FDv2SourceResult> Run() override;

void Close() override;

[[nodiscard]] std::string const& Identity() const override;

private:
flag_manager::FlagPersistence* const cache_;
Context const context_;
Logger logger_;
};

/**
* Builds fresh FDv2CacheInitializer instances on demand.
*
* Thread-safe: Build() may be called from any thread. The cache pointer and
* context it hands to each initializer are fixed at construction.
*/
class FDv2CacheInitializerFactory final : public IFDv2InitializerFactory {
public:
FDv2CacheInitializerFactory(flag_manager::FlagPersistence* cache,
Context context,
Logger const& logger);

std::unique_ptr<IFDv2Initializer> Build() override;

[[nodiscard]] bool IsFromCache() const override { return true; }

private:
flag_manager::FlagPersistence* const cache_;
Context const context_;
Logger logger_;
};

} // namespace launchdarkly::client_side::data_sources
10 changes: 10 additions & 0 deletions libs/client-sdk/src/flag_manager/context_index.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ void ContextIndex::Notice(
}
}

std::optional<std::chrono::time_point<std::chrono::system_clock>>
ContextIndex::GetTimestamp(std::string const& id) const {
for (auto const& entry : index_) {
if (entry.id == id) {
return entry.timestamp;
}
}
return std::nullopt;
}

std::vector<std::string> ContextIndex::Prune(std::size_t maxContexts) {
if (index_.size() <= maxContexts) {
return {};
Expand Down
13 changes: 13 additions & 0 deletions libs/client-sdk/src/flag_manager/context_index.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

#include <chrono>
#include <mutex>
#include <optional>
#include <string>
#include <vector>

Expand All @@ -21,6 +22,10 @@ namespace launchdarkly::client_side::flag_manager {
* 2. timestamp when it was last accessed, to support an LRU
* eviction pattern.
*/
/**
* Not thread-safe. Instances are short-lived values read out of persistence,
* modified, and written back by a caller holding its own lock.
*/
class ContextIndex {
public:
/**
Expand Down Expand Up @@ -52,6 +57,14 @@ class ContextIndex {

[[nodiscard]] Index const& Entries() const;

/**
* The timestamp recorded for the given id, or nullopt if the id is not in
* the index.
*/
[[nodiscard]] std::optional<
std::chrono::time_point<std::chrono::system_clock>>
GetTimestamp(std::string const& id) const;

/**
* Prune the index returning a list of the removed context keys
*
Expand Down
4 changes: 4 additions & 0 deletions libs/client-sdk/src/flag_manager/flag_manager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ FlagStore const& FlagManager::Store() const {
return flag_store_;
}

FlagPersistence& FlagManager::Cache() {
return persistence_updater_;
}

void FlagManager::LoadCache(Context const& context) {
persistence_updater_.LoadCached(context);
}
Expand Down
13 changes: 13 additions & 0 deletions libs/client-sdk/src/flag_manager/flag_manager.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@

namespace launchdarkly::client_side::flag_manager {

/**
* Owns the flag store and the update pipeline that feeds it.
*
* Thread-safe to the extent its parts are: the store, the updater, and the
* persistence layer each carry their own lock. The accessors hand out
* references to those parts and take no lock of their own.
*/
class FlagManager {
public:
FlagManager(std::string const& sdk_key,
Expand All @@ -18,6 +25,12 @@ class FlagManager {
IFlagNotifier& Notifier();
FlagStore const& Store() const;

/**
* The local cache the SDK persists flag data to, and which the FDv2 cache
* initializer reads from.
*/
FlagPersistence& Cache();

void LoadCache(Context const& context);

private:
Expand Down
69 changes: 53 additions & 16 deletions libs/client-sdk/src/flag_manager/flag_persistence.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include <launchdarkly/encoding/sha_256.hpp>

#include <launchdarkly/detail/serialization/json_primitives.hpp>
#include <launchdarkly/serialization/json_context.hpp>
#include <launchdarkly/serialization/json_evaluation_result.hpp>
#include <launchdarkly/serialization/json_item_descriptor.hpp>

Expand Down Expand Up @@ -59,25 +60,30 @@ void FlagPersistence::Apply(Context const& context,
change_set.type != data_model::ChangeSetType::kNone;
sink_.Apply(context, std::move(change_set), from_cache);
if (from_cache) {
// Writing cached data back to the cache it came from would be a no-op.
// Writing cached data back to the cache would be a no-op, and it was
// never confirmed current by the service.
return;
}
// Both a payload and a confirmation that nothing changed mean the cache
// is up to date as of now.
RecordFreshness(context);
if (changed_data) {
StoreCache(PersistenceEncodeKey(context.CanonicalKey()));
}
}

void FlagPersistence::LoadCached(Context const& context) {
std::optional<std::unordered_map<std::string, ItemDescriptor>>
FlagPersistence::ReadCached(Context const& context) {
if (!persistence_ || !context.Valid()) {
return;
return std::nullopt;
}

std::lock_guard lock(persistence_mutex_);
auto data = persistence_->Read(
environment_namespace_, PersistenceEncodeKey(context.CanonicalKey()));

if (!data) {
return;
return std::nullopt;
}

boost::system::error_code error_code;
Expand All @@ -86,24 +92,57 @@ void FlagPersistence::LoadCached(Context const& context) {
LD_LOG(logger_, LogLevel::kError)
<< "Failed to parse flag data from persistence: "
<< error_code.message();
return;
return std::nullopt;
}

auto res = boost::json::value_to<tl::expected<
std::optional<std::unordered_map<std::string, ItemDescriptor>>,
JsonError>>(parsed);
if (!res) {
LD_LOG(logger_, LogLevel::kError)
<< "Failed to parse flag data from persistence: "
<< error_code.message();
return;
<< "Failed to parse flag data from persistence";
return std::nullopt;
}

// If the map was null or omitted, treat it like an empty data set.
auto map =
res.value().value_or(std::unordered_map<std::string, ItemDescriptor>{});
return res.value().value_or(
std::unordered_map<std::string, ItemDescriptor>{});
}

void FlagPersistence::LoadCached(Context const& context) {
if (auto data = ReadCached(context)) {
sink_.Init(context, std::move(*data));
}
}

// Identifies a context by everything it carries, not just its key. Changing
// an attribute can change how flags evaluate.
static std::string FreshnessId(Context const& context) {
return PersistenceEncodeKey(
boost::json::serialize(boost::json::value_from(context)));
}

sink_.Init(context, std::move(map));
void FlagPersistence::RecordFreshness(Context const& context) {
if (!persistence_ || !context.Valid()) {
return;
}

std::lock_guard lock(persistence_mutex_);
auto index = ReadIndexAt(freshness_key_);
index.Notice(FreshnessId(context), time_stamper_());
index.Prune(max_cached_contexts_);
persistence_->Set(environment_namespace_, freshness_key_,
boost::json::serialize(boost::json::value_from(index)));
}

std::optional<std::chrono::time_point<std::chrono::system_clock>>
FlagPersistence::ReadFreshness(Context const& context) {
if (!persistence_ || !context.Valid()) {
return std::nullopt;
}

std::lock_guard lock(persistence_mutex_);
return ReadIndexAt(freshness_key_).GetTimestamp(FreshnessId(context));
}

void FlagPersistence::StoreCache(std::string const& context_id) {
Expand All @@ -112,7 +151,7 @@ void FlagPersistence::StoreCache(std::string const& context_id) {
}

std::lock_guard lock(persistence_mutex_);
auto index = GetIndex();
auto index = ReadIndexAt(index_key_);
index.Notice(context_id, time_stamper_());
auto pruned = index.Prune(max_cached_contexts_);
for (auto& id : pruned) {
Expand All @@ -127,11 +166,9 @@ void FlagPersistence::StoreCache(std::string const& context_id) {
boost::json::serialize(v));
}

ContextIndex FlagPersistence::GetIndex() {
ContextIndex FlagPersistence::ReadIndexAt(std::string const& key) {
if (persistence_) {
std::lock_guard lock(persistence_mutex_);
auto index_data =
persistence_->Read(environment_namespace_, index_key_);
auto index_data = persistence_->Read(environment_namespace_, key);

if (index_data) {
boost::system::error_code error_code;
Expand Down
Loading
Loading