From bf945b3b745029d9c6cc0d793612622fbac3ba88 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Fri, 27 Mar 2026 14:02:33 -0700 Subject: [PATCH 01/35] refactor: add Apply method to MemoryStore for transactional change sets --- .../memory_store/memory_store.cpp | 49 ++ .../memory_store/memory_store.hpp | 4 + .../tests/memory_store_apply_test.cpp | 551 ++++++++++++++++++ 3 files changed, 604 insertions(+) create mode 100644 libs/server-sdk/tests/memory_store_apply_test.cpp diff --git a/libs/server-sdk/src/data_components/memory_store/memory_store.cpp b/libs/server-sdk/src/data_components/memory_store/memory_store.cpp index 95cac8748..6e0365d7f 100644 --- a/libs/server-sdk/src/data_components/memory_store/memory_store.cpp +++ b/libs/server-sdk/src/data_components/memory_store/memory_store.cpp @@ -1,5 +1,9 @@ #include "memory_store.hpp" +#include + +#include + namespace launchdarkly::server_side::data_components { std::shared_ptr MemoryStore::GetFlag( @@ -82,4 +86,49 @@ bool MemoryStore::RemoveSegment(std::string const& key) { return segments_.erase(key) == 1; } +void MemoryStore::Apply(data_model::FDv2ChangeSet const& changeSet) { + std::lock_guard lock{data_mutex_}; + + if (changeSet.type == data_model::FDv2ChangeSet::Type::kNone) { + return; + } + + if (changeSet.type == data_model::FDv2ChangeSet::Type::kFull) { + initialized_ = true; + flags_.clear(); + segments_.clear(); + } + + for (auto change : changeSet.changes) { + if (std::holds_alternative(change.object)) { + auto& flag_descriptor = + std::get(change.object); + + auto existing_flag = flags_.find(change.key); + if (existing_flag != flags_.end() && + existing_flag->second->version >= flag_descriptor.version) { + continue; + } + + flags_[change.key] = std::make_shared( + std::move(flag_descriptor)); + } else if (std::holds_alternative( + change.object)) { + auto& segment_descriptor = + std::get(change.object); + + auto existing_segment = segments_.find(change.key); + if (existing_segment != segments_.end() && + existing_segment->second->version >= + segment_descriptor.version) { + continue; + } + + segments_[change.key] = + std::make_shared( + std::move(segment_descriptor)); + } + } +} + } // namespace launchdarkly::server_side::data_components diff --git a/libs/server-sdk/src/data_components/memory_store/memory_store.hpp b/libs/server-sdk/src/data_components/memory_store/memory_store.hpp index 93dfca485..81846dfab 100644 --- a/libs/server-sdk/src/data_components/memory_store/memory_store.hpp +++ b/libs/server-sdk/src/data_components/memory_store/memory_store.hpp @@ -3,6 +3,8 @@ #include "../../data_interfaces/destination/idestination.hpp" #include "../../data_interfaces/store/istore.hpp" +#include + #include #include #include @@ -44,6 +46,8 @@ class MemoryStore final : public data_interfaces::IStore, bool RemoveSegment(std::string const& key); + void Apply(data_model::FDv2ChangeSet const& changeSet); + MemoryStore() = default; ~MemoryStore() override = default; diff --git a/libs/server-sdk/tests/memory_store_apply_test.cpp b/libs/server-sdk/tests/memory_store_apply_test.cpp new file mode 100644 index 000000000..219e36a15 --- /dev/null +++ b/libs/server-sdk/tests/memory_store_apply_test.cpp @@ -0,0 +1,551 @@ +#include + +#include +#include + +using namespace launchdarkly::data_model; +using namespace launchdarkly::server_side::data_components; + +// --------------------------------------------------------------------------- +// kNone tests +// --------------------------------------------------------------------------- + +TEST(MemoryStoreApplyTest, ApplyNone_IsNoOp) { + MemoryStore store; + Flag flag_a; + flag_a.version = 1; + flag_a.key = "flagA"; + + Segment seg_a; + seg_a.version = 1; + seg_a.key = "segA"; + + store.Init(SDKDataSet{ + std::unordered_map{ + {"flagA", FlagDescriptor(flag_a)}}, + std::unordered_map{ + {"segA", SegmentDescriptor(seg_a)}}, + }); + + store.Apply(FDv2ChangeSet{FDv2ChangeSet::Type::kNone, {}, Selector{}}); + + auto fetched_flag = store.GetFlag("flagA"); + ASSERT_TRUE(fetched_flag); + EXPECT_EQ(1u, fetched_flag->version); + auto fetched_seg = store.GetSegment("segA"); + ASSERT_TRUE(fetched_seg); + EXPECT_EQ(1u, fetched_seg->version); +} + +TEST(MemoryStoreApplyTest, ApplyNone_DoesNotInitialize) { + MemoryStore store; + store.Apply(FDv2ChangeSet{FDv2ChangeSet::Type::kNone, {}, Selector{}}); + EXPECT_FALSE(store.Initialized()); +} + +// --------------------------------------------------------------------------- +// kFull tests +// --------------------------------------------------------------------------- + +TEST(MemoryStoreApplyTest, ApplyFull_SetsInitialized) { + MemoryStore store; + ASSERT_FALSE(store.Initialized()); + store.Apply(FDv2ChangeSet{FDv2ChangeSet::Type::kFull, {}, Selector{}}); + EXPECT_TRUE(store.Initialized()); +} + +TEST(MemoryStoreApplyTest, ApplyFull_WithFlag) { + MemoryStore store; + Flag flag_a; + flag_a.version = 1; + flag_a.key = "flagA"; + + store.Apply(FDv2ChangeSet{ + FDv2ChangeSet::Type::kFull, + std::vector{{"flagA", FlagDescriptor(flag_a)}}, + Selector{}, + }); + + auto fetched = store.GetFlag("flagA"); + ASSERT_TRUE(fetched); + EXPECT_TRUE(fetched->item.has_value()); + EXPECT_EQ("flagA", fetched->item->key); + EXPECT_EQ(1u, fetched->version); +} + +TEST(MemoryStoreApplyTest, ApplyFull_WithSegment) { + MemoryStore store; + Segment seg_a; + seg_a.version = 1; + seg_a.key = "segA"; + + store.Apply(FDv2ChangeSet{ + FDv2ChangeSet::Type::kFull, + std::vector{{"segA", SegmentDescriptor(seg_a)}}, + Selector{}, + }); + + auto fetched = store.GetSegment("segA"); + ASSERT_TRUE(fetched); + EXPECT_TRUE(fetched->item.has_value()); + EXPECT_EQ("segA", fetched->item->key); + EXPECT_EQ(1u, fetched->version); +} + +TEST(MemoryStoreApplyTest, ApplyFull_ClearsExistingFlags) { + MemoryStore store; + Flag flag_a; + flag_a.version = 1; + flag_a.key = "flagA"; + + Flag flag_b; + flag_b.version = 1; + flag_b.key = "flagB"; + + store.Init(SDKDataSet{ + std::unordered_map{ + {"flagA", FlagDescriptor(flag_a)}, + {"flagB", FlagDescriptor(flag_b)}}, + std::unordered_map(), + }); + + Flag flag_c; + flag_c.version = 1; + flag_c.key = "flagC"; + + store.Apply(FDv2ChangeSet{ + FDv2ChangeSet::Type::kFull, + std::vector{{"flagC", FlagDescriptor(flag_c)}}, + Selector{}, + }); + + EXPECT_FALSE(store.GetFlag("flagA")); + EXPECT_FALSE(store.GetFlag("flagB")); + ASSERT_TRUE(store.GetFlag("flagC")); + EXPECT_EQ("flagC", store.GetFlag("flagC")->item->key); +} + +TEST(MemoryStoreApplyTest, ApplyFull_ClearsExistingSegments) { + MemoryStore store; + Segment seg_a; + seg_a.version = 1; + seg_a.key = "segA"; + + store.Init(SDKDataSet{ + std::unordered_map(), + std::unordered_map{ + {"segA", SegmentDescriptor(seg_a)}}, + }); + + Segment seg_b; + seg_b.version = 1; + seg_b.key = "segB"; + + store.Apply(FDv2ChangeSet{ + FDv2ChangeSet::Type::kFull, + std::vector{{"segB", SegmentDescriptor(seg_b)}}, + Selector{}, + }); + + EXPECT_FALSE(store.GetSegment("segA")); + ASSERT_TRUE(store.GetSegment("segB")); +} + +TEST(MemoryStoreApplyTest, ApplyFull_EmptyChangeSetClearsStore) { + MemoryStore store; + Flag flag_a; + flag_a.version = 1; + flag_a.key = "flagA"; + + Segment seg_a; + seg_a.version = 1; + seg_a.key = "segA"; + + store.Init(SDKDataSet{ + std::unordered_map{ + {"flagA", FlagDescriptor(flag_a)}}, + std::unordered_map{ + {"segA", SegmentDescriptor(seg_a)}}, + }); + + store.Apply(FDv2ChangeSet{FDv2ChangeSet::Type::kFull, {}, Selector{}}); + + EXPECT_EQ(0u, store.AllFlags().size()); + EXPECT_EQ(0u, store.AllSegments().size()); +} + +TEST(MemoryStoreApplyTest, ApplyFull_WithFlagTombstone) { + MemoryStore store; + + store.Apply(FDv2ChangeSet{ + FDv2ChangeSet::Type::kFull, + std::vector{{"flagA", FlagDescriptor(Tombstone(5))}}, + Selector{}, + }); + + auto fetched = store.GetFlag("flagA"); + ASSERT_TRUE(fetched); + EXPECT_EQ(5u, fetched->version); + EXPECT_FALSE(fetched->item.has_value()); +} + +TEST(MemoryStoreApplyTest, ApplyFull_WithSegmentTombstone) { + MemoryStore store; + + store.Apply(FDv2ChangeSet{ + FDv2ChangeSet::Type::kFull, + std::vector{{"segA", SegmentDescriptor(Tombstone(3))}}, + Selector{}, + }); + + auto fetched = store.GetSegment("segA"); + ASSERT_TRUE(fetched); + EXPECT_EQ(3u, fetched->version); + EXPECT_FALSE(fetched->item.has_value()); +} + +// --------------------------------------------------------------------------- +// kPartial tests +// --------------------------------------------------------------------------- + +TEST(MemoryStoreApplyTest, ApplyPartial_UpsertsNewFlag) { + MemoryStore store; + store.Init(SDKDataSet{ + std::unordered_map(), + std::unordered_map(), + }); + + Flag flag_a; + flag_a.version = 1; + flag_a.key = "flagA"; + + store.Apply(FDv2ChangeSet{ + FDv2ChangeSet::Type::kPartial, + std::vector{{"flagA", FlagDescriptor(flag_a)}}, + Selector{}, + }); + + auto fetched = store.GetFlag("flagA"); + ASSERT_TRUE(fetched); + EXPECT_TRUE(fetched->item.has_value()); + EXPECT_EQ("flagA", fetched->item->key); + EXPECT_EQ(1u, fetched->version); +} + +TEST(MemoryStoreApplyTest, ApplyPartial_UpsertsNewSegment) { + MemoryStore store; + store.Init(SDKDataSet{ + std::unordered_map(), + std::unordered_map(), + }); + + Segment seg_a; + seg_a.version = 1; + seg_a.key = "segA"; + + store.Apply(FDv2ChangeSet{ + FDv2ChangeSet::Type::kPartial, + std::vector{{"segA", SegmentDescriptor(seg_a)}}, + Selector{}, + }); + + auto fetched = store.GetSegment("segA"); + ASSERT_TRUE(fetched); + EXPECT_TRUE(fetched->item.has_value()); + EXPECT_EQ("segA", fetched->item->key); + EXPECT_EQ(1u, fetched->version); +} + +TEST(MemoryStoreApplyTest, ApplyPartial_SkipsFlagWithLowerVersion) { + MemoryStore store; + Flag flag_a; + flag_a.version = 5; + flag_a.key = "flagA"; + + store.Init(SDKDataSet{ + std::unordered_map{ + {"flagA", FlagDescriptor(flag_a)}}, + std::unordered_map(), + }); + + Flag flag_a_stale; + flag_a_stale.version = 3; + flag_a_stale.key = "flagA"; + + store.Apply(FDv2ChangeSet{ + FDv2ChangeSet::Type::kPartial, + std::vector{{"flagA", FlagDescriptor(flag_a_stale)}}, + Selector{}, + }); + + auto fetched = store.GetFlag("flagA"); + ASSERT_TRUE(fetched); + EXPECT_EQ(5u, fetched->version); +} + +TEST(MemoryStoreApplyTest, ApplyPartial_SkipsFlagWithEqualVersion) { + MemoryStore store; + Flag flag_a; + flag_a.version = 5; + flag_a.key = "flagA"; + + store.Init(SDKDataSet{ + std::unordered_map{ + {"flagA", FlagDescriptor(flag_a)}}, + std::unordered_map(), + }); + + Flag flag_a_same; + flag_a_same.version = 5; + flag_a_same.key = "flagA"; + + store.Apply(FDv2ChangeSet{ + FDv2ChangeSet::Type::kPartial, + std::vector{{"flagA", FlagDescriptor(flag_a_same)}}, + Selector{}, + }); + + auto fetched = store.GetFlag("flagA"); + ASSERT_TRUE(fetched); + EXPECT_EQ(5u, fetched->version); +} + +TEST(MemoryStoreApplyTest, ApplyPartial_AppliesFlagWithHigherVersion) { + MemoryStore store; + Flag flag_a; + flag_a.version = 5; + flag_a.key = "flagA"; + + store.Init(SDKDataSet{ + std::unordered_map{ + {"flagA", FlagDescriptor(flag_a)}}, + std::unordered_map(), + }); + + Flag flag_a_new; + flag_a_new.version = 6; + flag_a_new.key = "flagA"; + + store.Apply(FDv2ChangeSet{ + FDv2ChangeSet::Type::kPartial, + std::vector{{"flagA", FlagDescriptor(flag_a_new)}}, + Selector{}, + }); + + auto fetched = store.GetFlag("flagA"); + ASSERT_TRUE(fetched); + EXPECT_EQ(6u, fetched->version); +} + +TEST(MemoryStoreApplyTest, ApplyPartial_SkipsSegmentWithLowerVersion) { + MemoryStore store; + Segment seg_a; + seg_a.version = 5; + seg_a.key = "segA"; + + store.Init(SDKDataSet{ + std::unordered_map(), + std::unordered_map{ + {"segA", SegmentDescriptor(seg_a)}}, + }); + + Segment seg_a_stale; + seg_a_stale.version = 3; + seg_a_stale.key = "segA"; + + store.Apply(FDv2ChangeSet{ + FDv2ChangeSet::Type::kPartial, + std::vector{{"segA", SegmentDescriptor(seg_a_stale)}}, + Selector{}, + }); + + auto fetched = store.GetSegment("segA"); + ASSERT_TRUE(fetched); + EXPECT_EQ(5u, fetched->version); +} + +TEST(MemoryStoreApplyTest, ApplyPartial_AppliesSegmentWithHigherVersion) { + MemoryStore store; + Segment seg_a; + seg_a.version = 5; + seg_a.key = "segA"; + + store.Init(SDKDataSet{ + std::unordered_map(), + std::unordered_map{ + {"segA", SegmentDescriptor(seg_a)}}, + }); + + Segment seg_a_new; + seg_a_new.version = 6; + seg_a_new.key = "segA"; + + store.Apply(FDv2ChangeSet{ + FDv2ChangeSet::Type::kPartial, + std::vector{{"segA", SegmentDescriptor(seg_a_new)}}, + Selector{}, + }); + + auto fetched = store.GetSegment("segA"); + ASSERT_TRUE(fetched); + EXPECT_EQ(6u, fetched->version); +} + +TEST(MemoryStoreApplyTest, ApplyPartial_PreservesUnchangedFlags) { + MemoryStore store; + Flag flag_a; + flag_a.version = 1; + flag_a.key = "flagA"; + + Flag flag_b; + flag_b.version = 1; + flag_b.key = "flagB"; + + store.Init(SDKDataSet{ + std::unordered_map{ + {"flagA", FlagDescriptor(flag_a)}, + {"flagB", FlagDescriptor(flag_b)}}, + std::unordered_map(), + }); + + Flag flag_b_new; + flag_b_new.version = 2; + flag_b_new.key = "flagB"; + + store.Apply(FDv2ChangeSet{ + FDv2ChangeSet::Type::kPartial, + std::vector{{"flagB", FlagDescriptor(flag_b_new)}}, + Selector{}, + }); + + auto fetched_a = store.GetFlag("flagA"); + ASSERT_TRUE(fetched_a); + EXPECT_EQ(1u, fetched_a->version); + + auto fetched_b = store.GetFlag("flagB"); + ASSERT_TRUE(fetched_b); + EXPECT_EQ(2u, fetched_b->version); +} + +TEST(MemoryStoreApplyTest, ApplyPartial_PreservesUnchangedSegments) { + MemoryStore store; + Segment seg_a; + seg_a.version = 1; + seg_a.key = "segA"; + + Segment seg_b; + seg_b.version = 1; + seg_b.key = "segB"; + + store.Init(SDKDataSet{ + std::unordered_map(), + std::unordered_map{ + {"segA", SegmentDescriptor(seg_a)}, + {"segB", SegmentDescriptor(seg_b)}}, + }); + + Segment seg_b_new; + seg_b_new.version = 2; + seg_b_new.key = "segB"; + + store.Apply(FDv2ChangeSet{ + FDv2ChangeSet::Type::kPartial, + std::vector{{"segB", SegmentDescriptor(seg_b_new)}}, + Selector{}, + }); + + auto fetched_a = store.GetSegment("segA"); + ASSERT_TRUE(fetched_a); + EXPECT_EQ(1u, fetched_a->version); + + auto fetched_b = store.GetSegment("segB"); + ASSERT_TRUE(fetched_b); + EXPECT_EQ(2u, fetched_b->version); +} + +TEST(MemoryStoreApplyTest, ApplyPartial_WithFlagTombstone) { + MemoryStore store; + Flag flag_a; + flag_a.version = 1; + flag_a.key = "flagA"; + + store.Init(SDKDataSet{ + std::unordered_map{ + {"flagA", FlagDescriptor(flag_a)}}, + std::unordered_map(), + }); + + store.Apply(FDv2ChangeSet{ + FDv2ChangeSet::Type::kPartial, + std::vector{{"flagA", FlagDescriptor(Tombstone(2))}}, + Selector{}, + }); + + auto fetched = store.GetFlag("flagA"); + ASSERT_TRUE(fetched); + EXPECT_EQ(2u, fetched->version); + EXPECT_FALSE(fetched->item.has_value()); +} + +TEST(MemoryStoreApplyTest, ApplyPartial_TombstoneSkippedIfVersionNotNewer) { + MemoryStore store; + Flag flag_a; + flag_a.version = 5; + flag_a.key = "flagA"; + + store.Init(SDKDataSet{ + std::unordered_map{ + {"flagA", FlagDescriptor(flag_a)}}, + std::unordered_map(), + }); + + // Tombstone at version 3 < stored version 5: should be ignored. + store.Apply(FDv2ChangeSet{ + FDv2ChangeSet::Type::kPartial, + std::vector{{"flagA", FlagDescriptor(Tombstone(3))}}, + Selector{}, + }); + + auto fetched = store.GetFlag("flagA"); + ASSERT_TRUE(fetched); + EXPECT_EQ(5u, fetched->version); + EXPECT_TRUE(fetched->item.has_value()); +} + +TEST(MemoryStoreApplyTest, ApplyPartial_MixedStaleAndFreshItems) { + MemoryStore store; + Flag flag_a; + flag_a.version = 10; + flag_a.key = "flagA"; + + Flag flag_b; + flag_b.version = 1; + flag_b.key = "flagB"; + + store.Init(SDKDataSet{ + std::unordered_map{ + {"flagA", FlagDescriptor(flag_a)}, + {"flagB", FlagDescriptor(flag_b)}}, + std::unordered_map(), + }); + + Flag flag_a_stale; + flag_a_stale.version = 5; + flag_a_stale.key = "flagA"; + + Flag flag_b_new; + flag_b_new.version = 2; + flag_b_new.key = "flagB"; + + store.Apply(FDv2ChangeSet{ + FDv2ChangeSet::Type::kPartial, + std::vector{{"flagA", FlagDescriptor(flag_a_stale)}, + {"flagB", FlagDescriptor(flag_b_new)}}, + Selector{}, + }); + + // flagA version 5 < 10: skip. + EXPECT_EQ(10u, store.GetFlag("flagA")->version); + // flagB version 2 > 1: apply. + EXPECT_EQ(2u, store.GetFlag("flagB")->version); +} From cc59635bccfd2990cc53ae65f5256f83f1fb5027 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Fri, 27 Mar 2026 14:19:23 -0700 Subject: [PATCH 02/35] fix an unneeded copy, and warn on missing enum case --- .../memory_store/memory_store.cpp | 28 ++++++++++--------- .../memory_store/memory_store.hpp | 2 +- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/libs/server-sdk/src/data_components/memory_store/memory_store.cpp b/libs/server-sdk/src/data_components/memory_store/memory_store.cpp index 6e0365d7f..7dc5105e3 100644 --- a/libs/server-sdk/src/data_components/memory_store/memory_store.cpp +++ b/libs/server-sdk/src/data_components/memory_store/memory_store.cpp @@ -1,8 +1,6 @@ #include "memory_store.hpp" -#include - -#include +#include namespace launchdarkly::server_side::data_components { @@ -86,20 +84,24 @@ bool MemoryStore::RemoveSegment(std::string const& key) { return segments_.erase(key) == 1; } -void MemoryStore::Apply(data_model::FDv2ChangeSet const& changeSet) { +void MemoryStore::Apply(data_model::FDv2ChangeSet changeSet) { std::lock_guard lock{data_mutex_}; - if (changeSet.type == data_model::FDv2ChangeSet::Type::kNone) { - return; - } - - if (changeSet.type == data_model::FDv2ChangeSet::Type::kFull) { - initialized_ = true; - flags_.clear(); - segments_.clear(); + switch (changeSet.type) { + case data_model::FDv2ChangeSet::Type::kNone: + return; + case data_model::FDv2ChangeSet::Type::kPartial: + break; + case data_model::FDv2ChangeSet::Type::kFull: + initialized_ = true; + flags_.clear(); + segments_.clear(); + break; + default: + detail::unreachable(); } - for (auto change : changeSet.changes) { + for (auto& change : changeSet.changes) { if (std::holds_alternative(change.object)) { auto& flag_descriptor = std::get(change.object); diff --git a/libs/server-sdk/src/data_components/memory_store/memory_store.hpp b/libs/server-sdk/src/data_components/memory_store/memory_store.hpp index 81846dfab..e9a067881 100644 --- a/libs/server-sdk/src/data_components/memory_store/memory_store.hpp +++ b/libs/server-sdk/src/data_components/memory_store/memory_store.hpp @@ -46,7 +46,7 @@ class MemoryStore final : public data_interfaces::IStore, bool RemoveSegment(std::string const& key); - void Apply(data_model::FDv2ChangeSet const& changeSet); + void Apply(data_model::FDv2ChangeSet changeSet); MemoryStore() = default; ~MemoryStore() override = default; From 82d09d26bd805d72012bf329d90933db9678aaef Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Fri, 27 Mar 2026 16:02:23 -0700 Subject: [PATCH 03/35] add an ApplyResult with the changed keys --- .../memory_store/memory_store.cpp | 17 ++- .../memory_store/memory_store.hpp | 8 +- .../tests/memory_store_apply_test.cpp | 123 +++++++++++++++--- 3 files changed, 125 insertions(+), 23 deletions(-) diff --git a/libs/server-sdk/src/data_components/memory_store/memory_store.cpp b/libs/server-sdk/src/data_components/memory_store/memory_store.cpp index 7dc5105e3..7680853a7 100644 --- a/libs/server-sdk/src/data_components/memory_store/memory_store.cpp +++ b/libs/server-sdk/src/data_components/memory_store/memory_store.cpp @@ -84,15 +84,24 @@ bool MemoryStore::RemoveSegment(std::string const& key) { return segments_.erase(key) == 1; } -void MemoryStore::Apply(data_model::FDv2ChangeSet changeSet) { +ApplyResult MemoryStore::Apply(data_model::FDv2ChangeSet changeSet) { + ApplyResult result; std::lock_guard lock{data_mutex_}; switch (changeSet.type) { case data_model::FDv2ChangeSet::Type::kNone: - return; + return result; case data_model::FDv2ChangeSet::Type::kPartial: break; case data_model::FDv2ChangeSet::Type::kFull: + // When there's a full change, any current keys are considered + // changed, regardless of whether they are in the new set. + for (auto const& [key, _] : flags_) { + result.flags.insert(key); + } + for (auto const& [key, _] : segments_) { + result.segments.insert(key); + } initialized_ = true; flags_.clear(); segments_.clear(); @@ -114,6 +123,7 @@ void MemoryStore::Apply(data_model::FDv2ChangeSet changeSet) { flags_[change.key] = std::make_shared( std::move(flag_descriptor)); + result.flags.insert(change.key); } else if (std::holds_alternative( change.object)) { auto& segment_descriptor = @@ -129,8 +139,11 @@ void MemoryStore::Apply(data_model::FDv2ChangeSet changeSet) { segments_[change.key] = std::make_shared( std::move(segment_descriptor)); + result.segments.insert(change.key); } } + + return result; } } // namespace launchdarkly::server_side::data_components diff --git a/libs/server-sdk/src/data_components/memory_store/memory_store.hpp b/libs/server-sdk/src/data_components/memory_store/memory_store.hpp index e9a067881..7712eb67e 100644 --- a/libs/server-sdk/src/data_components/memory_store/memory_store.hpp +++ b/libs/server-sdk/src/data_components/memory_store/memory_store.hpp @@ -9,9 +9,15 @@ #include #include #include +#include namespace launchdarkly::server_side::data_components { +struct ApplyResult { + std::unordered_set flags; + std::unordered_set segments; +}; + class MemoryStore final : public data_interfaces::IStore, public data_interfaces::IDestination { public: @@ -46,7 +52,7 @@ class MemoryStore final : public data_interfaces::IStore, bool RemoveSegment(std::string const& key); - void Apply(data_model::FDv2ChangeSet changeSet); + ApplyResult Apply(data_model::FDv2ChangeSet changeSet); MemoryStore() = default; ~MemoryStore() override = default; diff --git a/libs/server-sdk/tests/memory_store_apply_test.cpp b/libs/server-sdk/tests/memory_store_apply_test.cpp index 219e36a15..d0ab96868 100644 --- a/libs/server-sdk/tests/memory_store_apply_test.cpp +++ b/libs/server-sdk/tests/memory_store_apply_test.cpp @@ -27,7 +27,8 @@ TEST(MemoryStoreApplyTest, ApplyNone_IsNoOp) { {"segA", SegmentDescriptor(seg_a)}}, }); - store.Apply(FDv2ChangeSet{FDv2ChangeSet::Type::kNone, {}, Selector{}}); + auto result = + store.Apply(FDv2ChangeSet{FDv2ChangeSet::Type::kNone, {}, Selector{}}); auto fetched_flag = store.GetFlag("flagA"); ASSERT_TRUE(fetched_flag); @@ -35,6 +36,9 @@ TEST(MemoryStoreApplyTest, ApplyNone_IsNoOp) { auto fetched_seg = store.GetSegment("segA"); ASSERT_TRUE(fetched_seg); EXPECT_EQ(1u, fetched_seg->version); + + EXPECT_TRUE(result.flags.empty()); + EXPECT_TRUE(result.segments.empty()); } TEST(MemoryStoreApplyTest, ApplyNone_DoesNotInitialize) { @@ -60,7 +64,7 @@ TEST(MemoryStoreApplyTest, ApplyFull_WithFlag) { flag_a.version = 1; flag_a.key = "flagA"; - store.Apply(FDv2ChangeSet{ + auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kFull, std::vector{{"flagA", FlagDescriptor(flag_a)}}, Selector{}, @@ -71,6 +75,10 @@ TEST(MemoryStoreApplyTest, ApplyFull_WithFlag) { EXPECT_TRUE(fetched->item.has_value()); EXPECT_EQ("flagA", fetched->item->key); EXPECT_EQ(1u, fetched->version); + + ASSERT_EQ(1u, result.flags.size()); + EXPECT_EQ(1u, result.flags.count("flagA")); + EXPECT_TRUE(result.segments.empty()); } TEST(MemoryStoreApplyTest, ApplyFull_WithSegment) { @@ -79,7 +87,7 @@ TEST(MemoryStoreApplyTest, ApplyFull_WithSegment) { seg_a.version = 1; seg_a.key = "segA"; - store.Apply(FDv2ChangeSet{ + auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kFull, std::vector{{"segA", SegmentDescriptor(seg_a)}}, Selector{}, @@ -90,6 +98,10 @@ TEST(MemoryStoreApplyTest, ApplyFull_WithSegment) { EXPECT_TRUE(fetched->item.has_value()); EXPECT_EQ("segA", fetched->item->key); EXPECT_EQ(1u, fetched->version); + + EXPECT_TRUE(result.flags.empty()); + ASSERT_EQ(1u, result.segments.size()); + EXPECT_EQ(1u, result.segments.count("segA")); } TEST(MemoryStoreApplyTest, ApplyFull_ClearsExistingFlags) { @@ -113,7 +125,7 @@ TEST(MemoryStoreApplyTest, ApplyFull_ClearsExistingFlags) { flag_c.version = 1; flag_c.key = "flagC"; - store.Apply(FDv2ChangeSet{ + auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kFull, std::vector{{"flagC", FlagDescriptor(flag_c)}}, Selector{}, @@ -123,6 +135,13 @@ TEST(MemoryStoreApplyTest, ApplyFull_ClearsExistingFlags) { EXPECT_FALSE(store.GetFlag("flagB")); ASSERT_TRUE(store.GetFlag("flagC")); EXPECT_EQ("flagC", store.GetFlag("flagC")->item->key); + + // Cleared keys (flagA, flagB) and new key (flagC) all reported as changed. + ASSERT_EQ(3u, result.flags.size()); + EXPECT_EQ(1u, result.flags.count("flagA")); + EXPECT_EQ(1u, result.flags.count("flagB")); + EXPECT_EQ(1u, result.flags.count("flagC")); + EXPECT_TRUE(result.segments.empty()); } TEST(MemoryStoreApplyTest, ApplyFull_ClearsExistingSegments) { @@ -141,7 +160,7 @@ TEST(MemoryStoreApplyTest, ApplyFull_ClearsExistingSegments) { seg_b.version = 1; seg_b.key = "segB"; - store.Apply(FDv2ChangeSet{ + auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kFull, std::vector{{"segB", SegmentDescriptor(seg_b)}}, Selector{}, @@ -149,6 +168,12 @@ TEST(MemoryStoreApplyTest, ApplyFull_ClearsExistingSegments) { EXPECT_FALSE(store.GetSegment("segA")); ASSERT_TRUE(store.GetSegment("segB")); + + // Cleared key (segA) and new key (segB) both reported as changed. + EXPECT_TRUE(result.flags.empty()); + ASSERT_EQ(2u, result.segments.size()); + EXPECT_EQ(1u, result.segments.count("segA")); + EXPECT_EQ(1u, result.segments.count("segB")); } TEST(MemoryStoreApplyTest, ApplyFull_EmptyChangeSetClearsStore) { @@ -168,16 +193,22 @@ TEST(MemoryStoreApplyTest, ApplyFull_EmptyChangeSetClearsStore) { {"segA", SegmentDescriptor(seg_a)}}, }); - store.Apply(FDv2ChangeSet{FDv2ChangeSet::Type::kFull, {}, Selector{}}); + auto result = + store.Apply(FDv2ChangeSet{FDv2ChangeSet::Type::kFull, {}, Selector{}}); EXPECT_EQ(0u, store.AllFlags().size()); EXPECT_EQ(0u, store.AllSegments().size()); + + ASSERT_EQ(1u, result.flags.size()); + EXPECT_EQ(1u, result.flags.count("flagA")); + ASSERT_EQ(1u, result.segments.size()); + EXPECT_EQ(1u, result.segments.count("segA")); } TEST(MemoryStoreApplyTest, ApplyFull_WithFlagTombstone) { MemoryStore store; - store.Apply(FDv2ChangeSet{ + auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kFull, std::vector{{"flagA", FlagDescriptor(Tombstone(5))}}, Selector{}, @@ -187,12 +218,16 @@ TEST(MemoryStoreApplyTest, ApplyFull_WithFlagTombstone) { ASSERT_TRUE(fetched); EXPECT_EQ(5u, fetched->version); EXPECT_FALSE(fetched->item.has_value()); + + ASSERT_EQ(1u, result.flags.size()); + EXPECT_EQ(1u, result.flags.count("flagA")); + EXPECT_TRUE(result.segments.empty()); } TEST(MemoryStoreApplyTest, ApplyFull_WithSegmentTombstone) { MemoryStore store; - store.Apply(FDv2ChangeSet{ + auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kFull, std::vector{{"segA", SegmentDescriptor(Tombstone(3))}}, Selector{}, @@ -202,6 +237,10 @@ TEST(MemoryStoreApplyTest, ApplyFull_WithSegmentTombstone) { ASSERT_TRUE(fetched); EXPECT_EQ(3u, fetched->version); EXPECT_FALSE(fetched->item.has_value()); + + EXPECT_TRUE(result.flags.empty()); + ASSERT_EQ(1u, result.segments.size()); + EXPECT_EQ(1u, result.segments.count("segA")); } // --------------------------------------------------------------------------- @@ -219,7 +258,7 @@ TEST(MemoryStoreApplyTest, ApplyPartial_UpsertsNewFlag) { flag_a.version = 1; flag_a.key = "flagA"; - store.Apply(FDv2ChangeSet{ + auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kPartial, std::vector{{"flagA", FlagDescriptor(flag_a)}}, Selector{}, @@ -230,6 +269,10 @@ TEST(MemoryStoreApplyTest, ApplyPartial_UpsertsNewFlag) { EXPECT_TRUE(fetched->item.has_value()); EXPECT_EQ("flagA", fetched->item->key); EXPECT_EQ(1u, fetched->version); + + ASSERT_EQ(1u, result.flags.size()); + EXPECT_EQ(1u, result.flags.count("flagA")); + EXPECT_TRUE(result.segments.empty()); } TEST(MemoryStoreApplyTest, ApplyPartial_UpsertsNewSegment) { @@ -243,7 +286,7 @@ TEST(MemoryStoreApplyTest, ApplyPartial_UpsertsNewSegment) { seg_a.version = 1; seg_a.key = "segA"; - store.Apply(FDv2ChangeSet{ + auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kPartial, std::vector{{"segA", SegmentDescriptor(seg_a)}}, Selector{}, @@ -254,6 +297,10 @@ TEST(MemoryStoreApplyTest, ApplyPartial_UpsertsNewSegment) { EXPECT_TRUE(fetched->item.has_value()); EXPECT_EQ("segA", fetched->item->key); EXPECT_EQ(1u, fetched->version); + + EXPECT_TRUE(result.flags.empty()); + ASSERT_EQ(1u, result.segments.size()); + EXPECT_EQ(1u, result.segments.count("segA")); } TEST(MemoryStoreApplyTest, ApplyPartial_SkipsFlagWithLowerVersion) { @@ -272,7 +319,7 @@ TEST(MemoryStoreApplyTest, ApplyPartial_SkipsFlagWithLowerVersion) { flag_a_stale.version = 3; flag_a_stale.key = "flagA"; - store.Apply(FDv2ChangeSet{ + auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kPartial, std::vector{{"flagA", FlagDescriptor(flag_a_stale)}}, Selector{}, @@ -281,6 +328,9 @@ TEST(MemoryStoreApplyTest, ApplyPartial_SkipsFlagWithLowerVersion) { auto fetched = store.GetFlag("flagA"); ASSERT_TRUE(fetched); EXPECT_EQ(5u, fetched->version); + + EXPECT_TRUE(result.flags.empty()); + EXPECT_TRUE(result.segments.empty()); } TEST(MemoryStoreApplyTest, ApplyPartial_SkipsFlagWithEqualVersion) { @@ -299,7 +349,7 @@ TEST(MemoryStoreApplyTest, ApplyPartial_SkipsFlagWithEqualVersion) { flag_a_same.version = 5; flag_a_same.key = "flagA"; - store.Apply(FDv2ChangeSet{ + auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kPartial, std::vector{{"flagA", FlagDescriptor(flag_a_same)}}, Selector{}, @@ -308,6 +358,9 @@ TEST(MemoryStoreApplyTest, ApplyPartial_SkipsFlagWithEqualVersion) { auto fetched = store.GetFlag("flagA"); ASSERT_TRUE(fetched); EXPECT_EQ(5u, fetched->version); + + EXPECT_TRUE(result.flags.empty()); + EXPECT_TRUE(result.segments.empty()); } TEST(MemoryStoreApplyTest, ApplyPartial_AppliesFlagWithHigherVersion) { @@ -326,7 +379,7 @@ TEST(MemoryStoreApplyTest, ApplyPartial_AppliesFlagWithHigherVersion) { flag_a_new.version = 6; flag_a_new.key = "flagA"; - store.Apply(FDv2ChangeSet{ + auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kPartial, std::vector{{"flagA", FlagDescriptor(flag_a_new)}}, Selector{}, @@ -335,6 +388,10 @@ TEST(MemoryStoreApplyTest, ApplyPartial_AppliesFlagWithHigherVersion) { auto fetched = store.GetFlag("flagA"); ASSERT_TRUE(fetched); EXPECT_EQ(6u, fetched->version); + + ASSERT_EQ(1u, result.flags.size()); + EXPECT_EQ(1u, result.flags.count("flagA")); + EXPECT_TRUE(result.segments.empty()); } TEST(MemoryStoreApplyTest, ApplyPartial_SkipsSegmentWithLowerVersion) { @@ -353,7 +410,7 @@ TEST(MemoryStoreApplyTest, ApplyPartial_SkipsSegmentWithLowerVersion) { seg_a_stale.version = 3; seg_a_stale.key = "segA"; - store.Apply(FDv2ChangeSet{ + auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kPartial, std::vector{{"segA", SegmentDescriptor(seg_a_stale)}}, Selector{}, @@ -362,6 +419,9 @@ TEST(MemoryStoreApplyTest, ApplyPartial_SkipsSegmentWithLowerVersion) { auto fetched = store.GetSegment("segA"); ASSERT_TRUE(fetched); EXPECT_EQ(5u, fetched->version); + + EXPECT_TRUE(result.flags.empty()); + EXPECT_TRUE(result.segments.empty()); } TEST(MemoryStoreApplyTest, ApplyPartial_AppliesSegmentWithHigherVersion) { @@ -380,7 +440,7 @@ TEST(MemoryStoreApplyTest, ApplyPartial_AppliesSegmentWithHigherVersion) { seg_a_new.version = 6; seg_a_new.key = "segA"; - store.Apply(FDv2ChangeSet{ + auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kPartial, std::vector{{"segA", SegmentDescriptor(seg_a_new)}}, Selector{}, @@ -389,6 +449,10 @@ TEST(MemoryStoreApplyTest, ApplyPartial_AppliesSegmentWithHigherVersion) { auto fetched = store.GetSegment("segA"); ASSERT_TRUE(fetched); EXPECT_EQ(6u, fetched->version); + + EXPECT_TRUE(result.flags.empty()); + ASSERT_EQ(1u, result.segments.size()); + EXPECT_EQ(1u, result.segments.count("segA")); } TEST(MemoryStoreApplyTest, ApplyPartial_PreservesUnchangedFlags) { @@ -412,7 +476,7 @@ TEST(MemoryStoreApplyTest, ApplyPartial_PreservesUnchangedFlags) { flag_b_new.version = 2; flag_b_new.key = "flagB"; - store.Apply(FDv2ChangeSet{ + auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kPartial, std::vector{{"flagB", FlagDescriptor(flag_b_new)}}, Selector{}, @@ -425,6 +489,10 @@ TEST(MemoryStoreApplyTest, ApplyPartial_PreservesUnchangedFlags) { auto fetched_b = store.GetFlag("flagB"); ASSERT_TRUE(fetched_b); EXPECT_EQ(2u, fetched_b->version); + + ASSERT_EQ(1u, result.flags.size()); + EXPECT_EQ(1u, result.flags.count("flagB")); + EXPECT_TRUE(result.segments.empty()); } TEST(MemoryStoreApplyTest, ApplyPartial_PreservesUnchangedSegments) { @@ -448,7 +516,7 @@ TEST(MemoryStoreApplyTest, ApplyPartial_PreservesUnchangedSegments) { seg_b_new.version = 2; seg_b_new.key = "segB"; - store.Apply(FDv2ChangeSet{ + auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kPartial, std::vector{{"segB", SegmentDescriptor(seg_b_new)}}, Selector{}, @@ -461,6 +529,10 @@ TEST(MemoryStoreApplyTest, ApplyPartial_PreservesUnchangedSegments) { auto fetched_b = store.GetSegment("segB"); ASSERT_TRUE(fetched_b); EXPECT_EQ(2u, fetched_b->version); + + EXPECT_TRUE(result.flags.empty()); + ASSERT_EQ(1u, result.segments.size()); + EXPECT_EQ(1u, result.segments.count("segB")); } TEST(MemoryStoreApplyTest, ApplyPartial_WithFlagTombstone) { @@ -475,7 +547,7 @@ TEST(MemoryStoreApplyTest, ApplyPartial_WithFlagTombstone) { std::unordered_map(), }); - store.Apply(FDv2ChangeSet{ + auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kPartial, std::vector{{"flagA", FlagDescriptor(Tombstone(2))}}, Selector{}, @@ -485,6 +557,10 @@ TEST(MemoryStoreApplyTest, ApplyPartial_WithFlagTombstone) { ASSERT_TRUE(fetched); EXPECT_EQ(2u, fetched->version); EXPECT_FALSE(fetched->item.has_value()); + + ASSERT_EQ(1u, result.flags.size()); + EXPECT_EQ(1u, result.flags.count("flagA")); + EXPECT_TRUE(result.segments.empty()); } TEST(MemoryStoreApplyTest, ApplyPartial_TombstoneSkippedIfVersionNotNewer) { @@ -500,7 +576,7 @@ TEST(MemoryStoreApplyTest, ApplyPartial_TombstoneSkippedIfVersionNotNewer) { }); // Tombstone at version 3 < stored version 5: should be ignored. - store.Apply(FDv2ChangeSet{ + auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kPartial, std::vector{{"flagA", FlagDescriptor(Tombstone(3))}}, Selector{}, @@ -510,6 +586,9 @@ TEST(MemoryStoreApplyTest, ApplyPartial_TombstoneSkippedIfVersionNotNewer) { ASSERT_TRUE(fetched); EXPECT_EQ(5u, fetched->version); EXPECT_TRUE(fetched->item.has_value()); + + EXPECT_TRUE(result.flags.empty()); + EXPECT_TRUE(result.segments.empty()); } TEST(MemoryStoreApplyTest, ApplyPartial_MixedStaleAndFreshItems) { @@ -537,7 +616,7 @@ TEST(MemoryStoreApplyTest, ApplyPartial_MixedStaleAndFreshItems) { flag_b_new.version = 2; flag_b_new.key = "flagB"; - store.Apply(FDv2ChangeSet{ + auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kPartial, std::vector{{"flagA", FlagDescriptor(flag_a_stale)}, {"flagB", FlagDescriptor(flag_b_new)}}, @@ -548,4 +627,8 @@ TEST(MemoryStoreApplyTest, ApplyPartial_MixedStaleAndFreshItems) { EXPECT_EQ(10u, store.GetFlag("flagA")->version); // flagB version 2 > 1: apply. EXPECT_EQ(2u, store.GetFlag("flagB")->version); + + ASSERT_EQ(1u, result.flags.size()); + EXPECT_EQ(1u, result.flags.count("flagB")); + EXPECT_TRUE(result.segments.empty()); } From 259d8e901e9b4d2f76a85e63f423213e6cbf6340 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Fri, 27 Mar 2026 16:08:41 -0700 Subject: [PATCH 04/35] mark the apply result as nodiscard for now --- .../src/data_components/memory_store/memory_store.cpp | 2 +- .../src/data_components/memory_store/memory_store.hpp | 2 +- libs/server-sdk/tests/memory_store_apply_test.cpp | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/server-sdk/src/data_components/memory_store/memory_store.cpp b/libs/server-sdk/src/data_components/memory_store/memory_store.cpp index 7680853a7..d8e9474af 100644 --- a/libs/server-sdk/src/data_components/memory_store/memory_store.cpp +++ b/libs/server-sdk/src/data_components/memory_store/memory_store.cpp @@ -85,8 +85,8 @@ bool MemoryStore::RemoveSegment(std::string const& key) { } ApplyResult MemoryStore::Apply(data_model::FDv2ChangeSet changeSet) { - ApplyResult result; std::lock_guard lock{data_mutex_}; + ApplyResult result; switch (changeSet.type) { case data_model::FDv2ChangeSet::Type::kNone: diff --git a/libs/server-sdk/src/data_components/memory_store/memory_store.hpp b/libs/server-sdk/src/data_components/memory_store/memory_store.hpp index 7712eb67e..5013adcce 100644 --- a/libs/server-sdk/src/data_components/memory_store/memory_store.hpp +++ b/libs/server-sdk/src/data_components/memory_store/memory_store.hpp @@ -52,7 +52,7 @@ class MemoryStore final : public data_interfaces::IStore, bool RemoveSegment(std::string const& key); - ApplyResult Apply(data_model::FDv2ChangeSet changeSet); + [[nodiscard]] ApplyResult Apply(data_model::FDv2ChangeSet changeSet); MemoryStore() = default; ~MemoryStore() override = default; diff --git a/libs/server-sdk/tests/memory_store_apply_test.cpp b/libs/server-sdk/tests/memory_store_apply_test.cpp index d0ab96868..844076559 100644 --- a/libs/server-sdk/tests/memory_store_apply_test.cpp +++ b/libs/server-sdk/tests/memory_store_apply_test.cpp @@ -43,7 +43,7 @@ TEST(MemoryStoreApplyTest, ApplyNone_IsNoOp) { TEST(MemoryStoreApplyTest, ApplyNone_DoesNotInitialize) { MemoryStore store; - store.Apply(FDv2ChangeSet{FDv2ChangeSet::Type::kNone, {}, Selector{}}); + std::ignore = store.Apply(FDv2ChangeSet{FDv2ChangeSet::Type::kNone, {}, Selector{}}); EXPECT_FALSE(store.Initialized()); } @@ -54,7 +54,7 @@ TEST(MemoryStoreApplyTest, ApplyNone_DoesNotInitialize) { TEST(MemoryStoreApplyTest, ApplyFull_SetsInitialized) { MemoryStore store; ASSERT_FALSE(store.Initialized()); - store.Apply(FDv2ChangeSet{FDv2ChangeSet::Type::kFull, {}, Selector{}}); + std::ignore = store.Apply(FDv2ChangeSet{FDv2ChangeSet::Type::kFull, {}, Selector{}}); EXPECT_TRUE(store.Initialized()); } From f5b82bf6240b0cec36943d2c8de7cd5162414df6 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Fri, 27 Mar 2026 16:19:50 -0700 Subject: [PATCH 05/35] simplify tests --- .../tests/memory_store_apply_test.cpp | 381 ++++++------------ 1 file changed, 120 insertions(+), 261 deletions(-) diff --git a/libs/server-sdk/tests/memory_store_apply_test.cpp b/libs/server-sdk/tests/memory_store_apply_test.cpp index 844076559..619fe380b 100644 --- a/libs/server-sdk/tests/memory_store_apply_test.cpp +++ b/libs/server-sdk/tests/memory_store_apply_test.cpp @@ -43,7 +43,8 @@ TEST(MemoryStoreApplyTest, ApplyNone_IsNoOp) { TEST(MemoryStoreApplyTest, ApplyNone_DoesNotInitialize) { MemoryStore store; - std::ignore = store.Apply(FDv2ChangeSet{FDv2ChangeSet::Type::kNone, {}, Selector{}}); + std::ignore = store.Apply( + FDv2ChangeSet{FDv2ChangeSet::Type::kNone, {}, Selector{}}); EXPECT_FALSE(store.Initialized()); } @@ -54,57 +55,47 @@ TEST(MemoryStoreApplyTest, ApplyNone_DoesNotInitialize) { TEST(MemoryStoreApplyTest, ApplyFull_SetsInitialized) { MemoryStore store; ASSERT_FALSE(store.Initialized()); - std::ignore = store.Apply(FDv2ChangeSet{FDv2ChangeSet::Type::kFull, {}, Selector{}}); + std::ignore = store.Apply( + FDv2ChangeSet{FDv2ChangeSet::Type::kFull, {}, Selector{}}); EXPECT_TRUE(store.Initialized()); } -TEST(MemoryStoreApplyTest, ApplyFull_WithFlag) { +TEST(MemoryStoreApplyTest, ApplyFull_StoresItems) { MemoryStore store; Flag flag_a; flag_a.version = 1; flag_a.key = "flagA"; - auto result = store.Apply(FDv2ChangeSet{ - FDv2ChangeSet::Type::kFull, - std::vector{{"flagA", FlagDescriptor(flag_a)}}, - Selector{}, - }); - - auto fetched = store.GetFlag("flagA"); - ASSERT_TRUE(fetched); - EXPECT_TRUE(fetched->item.has_value()); - EXPECT_EQ("flagA", fetched->item->key); - EXPECT_EQ(1u, fetched->version); - - ASSERT_EQ(1u, result.flags.size()); - EXPECT_EQ(1u, result.flags.count("flagA")); - EXPECT_TRUE(result.segments.empty()); -} - -TEST(MemoryStoreApplyTest, ApplyFull_WithSegment) { - MemoryStore store; Segment seg_a; seg_a.version = 1; seg_a.key = "segA"; auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kFull, - std::vector{{"segA", SegmentDescriptor(seg_a)}}, + std::vector{{"flagA", FlagDescriptor(flag_a)}, + {"segA", SegmentDescriptor(seg_a)}}, Selector{}, }); - auto fetched = store.GetSegment("segA"); - ASSERT_TRUE(fetched); - EXPECT_TRUE(fetched->item.has_value()); - EXPECT_EQ("segA", fetched->item->key); - EXPECT_EQ(1u, fetched->version); + auto fetched_flag = store.GetFlag("flagA"); + ASSERT_TRUE(fetched_flag); + EXPECT_TRUE(fetched_flag->item.has_value()); + EXPECT_EQ("flagA", fetched_flag->item->key); + EXPECT_EQ(1u, fetched_flag->version); - EXPECT_TRUE(result.flags.empty()); + auto fetched_seg = store.GetSegment("segA"); + ASSERT_TRUE(fetched_seg); + EXPECT_TRUE(fetched_seg->item.has_value()); + EXPECT_EQ("segA", fetched_seg->item->key); + EXPECT_EQ(1u, fetched_seg->version); + + ASSERT_EQ(1u, result.flags.size()); + EXPECT_EQ(1u, result.flags.count("flagA")); ASSERT_EQ(1u, result.segments.size()); EXPECT_EQ(1u, result.segments.count("segA")); } -TEST(MemoryStoreApplyTest, ApplyFull_ClearsExistingFlags) { +TEST(MemoryStoreApplyTest, ApplyFull_ClearsExistingItems) { MemoryStore store; Flag flag_a; flag_a.version = 1; @@ -114,63 +105,44 @@ TEST(MemoryStoreApplyTest, ApplyFull_ClearsExistingFlags) { flag_b.version = 1; flag_b.key = "flagB"; + Segment seg_a; + seg_a.version = 1; + seg_a.key = "segA"; + store.Init(SDKDataSet{ std::unordered_map{ {"flagA", FlagDescriptor(flag_a)}, {"flagB", FlagDescriptor(flag_b)}}, - std::unordered_map(), + std::unordered_map{ + {"segA", SegmentDescriptor(seg_a)}}, }); Flag flag_c; flag_c.version = 1; flag_c.key = "flagC"; + Segment seg_b; + seg_b.version = 1; + seg_b.key = "segB"; + auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kFull, - std::vector{{"flagC", FlagDescriptor(flag_c)}}, + std::vector{{"flagC", FlagDescriptor(flag_c)}, + {"segB", SegmentDescriptor(seg_b)}}, Selector{}, }); EXPECT_FALSE(store.GetFlag("flagA")); EXPECT_FALSE(store.GetFlag("flagB")); ASSERT_TRUE(store.GetFlag("flagC")); - EXPECT_EQ("flagC", store.GetFlag("flagC")->item->key); + EXPECT_FALSE(store.GetSegment("segA")); + ASSERT_TRUE(store.GetSegment("segB")); - // Cleared keys (flagA, flagB) and new key (flagC) all reported as changed. + // Cleared keys and new keys all reported as changed. ASSERT_EQ(3u, result.flags.size()); EXPECT_EQ(1u, result.flags.count("flagA")); EXPECT_EQ(1u, result.flags.count("flagB")); EXPECT_EQ(1u, result.flags.count("flagC")); - EXPECT_TRUE(result.segments.empty()); -} - -TEST(MemoryStoreApplyTest, ApplyFull_ClearsExistingSegments) { - MemoryStore store; - Segment seg_a; - seg_a.version = 1; - seg_a.key = "segA"; - - store.Init(SDKDataSet{ - std::unordered_map(), - std::unordered_map{ - {"segA", SegmentDescriptor(seg_a)}}, - }); - - Segment seg_b; - seg_b.version = 1; - seg_b.key = "segB"; - - auto result = store.Apply(FDv2ChangeSet{ - FDv2ChangeSet::Type::kFull, - std::vector{{"segB", SegmentDescriptor(seg_b)}}, - Selector{}, - }); - - EXPECT_FALSE(store.GetSegment("segA")); - ASSERT_TRUE(store.GetSegment("segB")); - - // Cleared key (segA) and new key (segB) both reported as changed. - EXPECT_TRUE(result.flags.empty()); ASSERT_EQ(2u, result.segments.size()); EXPECT_EQ(1u, result.segments.count("segA")); EXPECT_EQ(1u, result.segments.count("segB")); @@ -224,30 +196,11 @@ TEST(MemoryStoreApplyTest, ApplyFull_WithFlagTombstone) { EXPECT_TRUE(result.segments.empty()); } -TEST(MemoryStoreApplyTest, ApplyFull_WithSegmentTombstone) { - MemoryStore store; - - auto result = store.Apply(FDv2ChangeSet{ - FDv2ChangeSet::Type::kFull, - std::vector{{"segA", SegmentDescriptor(Tombstone(3))}}, - Selector{}, - }); - - auto fetched = store.GetSegment("segA"); - ASSERT_TRUE(fetched); - EXPECT_EQ(3u, fetched->version); - EXPECT_FALSE(fetched->item.has_value()); - - EXPECT_TRUE(result.flags.empty()); - ASSERT_EQ(1u, result.segments.size()); - EXPECT_EQ(1u, result.segments.count("segA")); -} - // --------------------------------------------------------------------------- // kPartial tests // --------------------------------------------------------------------------- -TEST(MemoryStoreApplyTest, ApplyPartial_UpsertsNewFlag) { +TEST(MemoryStoreApplyTest, ApplyPartial_UpsertsNewItems) { MemoryStore store; store.Init(SDKDataSet{ std::unordered_map(), @@ -258,183 +211,137 @@ TEST(MemoryStoreApplyTest, ApplyPartial_UpsertsNewFlag) { flag_a.version = 1; flag_a.key = "flagA"; - auto result = store.Apply(FDv2ChangeSet{ - FDv2ChangeSet::Type::kPartial, - std::vector{{"flagA", FlagDescriptor(flag_a)}}, - Selector{}, - }); - - auto fetched = store.GetFlag("flagA"); - ASSERT_TRUE(fetched); - EXPECT_TRUE(fetched->item.has_value()); - EXPECT_EQ("flagA", fetched->item->key); - EXPECT_EQ(1u, fetched->version); - - ASSERT_EQ(1u, result.flags.size()); - EXPECT_EQ(1u, result.flags.count("flagA")); - EXPECT_TRUE(result.segments.empty()); -} - -TEST(MemoryStoreApplyTest, ApplyPartial_UpsertsNewSegment) { - MemoryStore store; - store.Init(SDKDataSet{ - std::unordered_map(), - std::unordered_map(), - }); - Segment seg_a; seg_a.version = 1; seg_a.key = "segA"; auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kPartial, - std::vector{{"segA", SegmentDescriptor(seg_a)}}, + std::vector{{"flagA", FlagDescriptor(flag_a)}, + {"segA", SegmentDescriptor(seg_a)}}, Selector{}, }); - auto fetched = store.GetSegment("segA"); - ASSERT_TRUE(fetched); - EXPECT_TRUE(fetched->item.has_value()); - EXPECT_EQ("segA", fetched->item->key); - EXPECT_EQ(1u, fetched->version); + auto fetched_flag = store.GetFlag("flagA"); + ASSERT_TRUE(fetched_flag); + EXPECT_TRUE(fetched_flag->item.has_value()); + EXPECT_EQ("flagA", fetched_flag->item->key); + EXPECT_EQ(1u, fetched_flag->version); - EXPECT_TRUE(result.flags.empty()); + auto fetched_seg = store.GetSegment("segA"); + ASSERT_TRUE(fetched_seg); + EXPECT_TRUE(fetched_seg->item.has_value()); + EXPECT_EQ("segA", fetched_seg->item->key); + EXPECT_EQ(1u, fetched_seg->version); + + ASSERT_EQ(1u, result.flags.size()); + EXPECT_EQ(1u, result.flags.count("flagA")); ASSERT_EQ(1u, result.segments.size()); EXPECT_EQ(1u, result.segments.count("segA")); } -TEST(MemoryStoreApplyTest, ApplyPartial_SkipsFlagWithLowerVersion) { +TEST(MemoryStoreApplyTest, ApplyPartial_SkipsStaleItems) { MemoryStore store; Flag flag_a; flag_a.version = 5; flag_a.key = "flagA"; + Segment seg_a; + seg_a.version = 5; + seg_a.key = "segA"; + store.Init(SDKDataSet{ std::unordered_map{ {"flagA", FlagDescriptor(flag_a)}}, - std::unordered_map(), + std::unordered_map{ + {"segA", SegmentDescriptor(seg_a)}}, }); Flag flag_a_stale; flag_a_stale.version = 3; flag_a_stale.key = "flagA"; + Segment seg_a_stale; + seg_a_stale.version = 3; + seg_a_stale.key = "segA"; + auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kPartial, - std::vector{{"flagA", FlagDescriptor(flag_a_stale)}}, + std::vector{{"flagA", FlagDescriptor(flag_a_stale)}, + {"segA", SegmentDescriptor(seg_a_stale)}}, Selector{}, }); - auto fetched = store.GetFlag("flagA"); - ASSERT_TRUE(fetched); - EXPECT_EQ(5u, fetched->version); + ASSERT_TRUE(store.GetFlag("flagA")); + EXPECT_EQ(5u, store.GetFlag("flagA")->version); + ASSERT_TRUE(store.GetSegment("segA")); + EXPECT_EQ(5u, store.GetSegment("segA")->version); EXPECT_TRUE(result.flags.empty()); EXPECT_TRUE(result.segments.empty()); } -TEST(MemoryStoreApplyTest, ApplyPartial_SkipsFlagWithEqualVersion) { +TEST(MemoryStoreApplyTest, ApplyPartial_SkipsItemsWithEqualVersion) { MemoryStore store; Flag flag_a; flag_a.version = 5; flag_a.key = "flagA"; + Segment seg_a; + seg_a.version = 5; + seg_a.key = "segA"; + store.Init(SDKDataSet{ std::unordered_map{ {"flagA", FlagDescriptor(flag_a)}}, - std::unordered_map(), + std::unordered_map{ + {"segA", SegmentDescriptor(seg_a)}}, }); Flag flag_a_same; flag_a_same.version = 5; flag_a_same.key = "flagA"; + Segment seg_a_same; + seg_a_same.version = 5; + seg_a_same.key = "segA"; + auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kPartial, - std::vector{{"flagA", FlagDescriptor(flag_a_same)}}, + std::vector{{"flagA", FlagDescriptor(flag_a_same)}, + {"segA", SegmentDescriptor(seg_a_same)}}, Selector{}, }); - auto fetched = store.GetFlag("flagA"); - ASSERT_TRUE(fetched); - EXPECT_EQ(5u, fetched->version); + ASSERT_TRUE(store.GetFlag("flagA")); + EXPECT_EQ(5u, store.GetFlag("flagA")->version); + ASSERT_TRUE(store.GetSegment("segA")); + EXPECT_EQ(5u, store.GetSegment("segA")->version); EXPECT_TRUE(result.flags.empty()); EXPECT_TRUE(result.segments.empty()); } -TEST(MemoryStoreApplyTest, ApplyPartial_AppliesFlagWithHigherVersion) { +TEST(MemoryStoreApplyTest, ApplyPartial_AppliesFreshItems) { MemoryStore store; Flag flag_a; flag_a.version = 5; flag_a.key = "flagA"; - store.Init(SDKDataSet{ - std::unordered_map{ - {"flagA", FlagDescriptor(flag_a)}}, - std::unordered_map(), - }); - - Flag flag_a_new; - flag_a_new.version = 6; - flag_a_new.key = "flagA"; - - auto result = store.Apply(FDv2ChangeSet{ - FDv2ChangeSet::Type::kPartial, - std::vector{{"flagA", FlagDescriptor(flag_a_new)}}, - Selector{}, - }); - - auto fetched = store.GetFlag("flagA"); - ASSERT_TRUE(fetched); - EXPECT_EQ(6u, fetched->version); - - ASSERT_EQ(1u, result.flags.size()); - EXPECT_EQ(1u, result.flags.count("flagA")); - EXPECT_TRUE(result.segments.empty()); -} - -TEST(MemoryStoreApplyTest, ApplyPartial_SkipsSegmentWithLowerVersion) { - MemoryStore store; Segment seg_a; seg_a.version = 5; seg_a.key = "segA"; store.Init(SDKDataSet{ - std::unordered_map(), + std::unordered_map{ + {"flagA", FlagDescriptor(flag_a)}}, std::unordered_map{ {"segA", SegmentDescriptor(seg_a)}}, }); - Segment seg_a_stale; - seg_a_stale.version = 3; - seg_a_stale.key = "segA"; - - auto result = store.Apply(FDv2ChangeSet{ - FDv2ChangeSet::Type::kPartial, - std::vector{{"segA", SegmentDescriptor(seg_a_stale)}}, - Selector{}, - }); - - auto fetched = store.GetSegment("segA"); - ASSERT_TRUE(fetched); - EXPECT_EQ(5u, fetched->version); - - EXPECT_TRUE(result.flags.empty()); - EXPECT_TRUE(result.segments.empty()); -} - -TEST(MemoryStoreApplyTest, ApplyPartial_AppliesSegmentWithHigherVersion) { - MemoryStore store; - Segment seg_a; - seg_a.version = 5; - seg_a.key = "segA"; - - store.Init(SDKDataSet{ - std::unordered_map(), - std::unordered_map{ - {"segA", SegmentDescriptor(seg_a)}}, - }); + Flag flag_a_new; + flag_a_new.version = 6; + flag_a_new.key = "flagA"; Segment seg_a_new; seg_a_new.version = 6; @@ -442,20 +349,23 @@ TEST(MemoryStoreApplyTest, ApplyPartial_AppliesSegmentWithHigherVersion) { auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kPartial, - std::vector{{"segA", SegmentDescriptor(seg_a_new)}}, + std::vector{{"flagA", FlagDescriptor(flag_a_new)}, + {"segA", SegmentDescriptor(seg_a_new)}}, Selector{}, }); - auto fetched = store.GetSegment("segA"); - ASSERT_TRUE(fetched); - EXPECT_EQ(6u, fetched->version); + ASSERT_TRUE(store.GetFlag("flagA")); + EXPECT_EQ(6u, store.GetFlag("flagA")->version); + ASSERT_TRUE(store.GetSegment("segA")); + EXPECT_EQ(6u, store.GetSegment("segA")->version); - EXPECT_TRUE(result.flags.empty()); + ASSERT_EQ(1u, result.flags.size()); + EXPECT_EQ(1u, result.flags.count("flagA")); ASSERT_EQ(1u, result.segments.size()); EXPECT_EQ(1u, result.segments.count("segA")); } -TEST(MemoryStoreApplyTest, ApplyPartial_PreservesUnchangedFlags) { +TEST(MemoryStoreApplyTest, ApplyPartial_PreservesUnchangedItems) { MemoryStore store; Flag flag_a; flag_a.version = 1; @@ -465,38 +375,6 @@ TEST(MemoryStoreApplyTest, ApplyPartial_PreservesUnchangedFlags) { flag_b.version = 1; flag_b.key = "flagB"; - store.Init(SDKDataSet{ - std::unordered_map{ - {"flagA", FlagDescriptor(flag_a)}, - {"flagB", FlagDescriptor(flag_b)}}, - std::unordered_map(), - }); - - Flag flag_b_new; - flag_b_new.version = 2; - flag_b_new.key = "flagB"; - - auto result = store.Apply(FDv2ChangeSet{ - FDv2ChangeSet::Type::kPartial, - std::vector{{"flagB", FlagDescriptor(flag_b_new)}}, - Selector{}, - }); - - auto fetched_a = store.GetFlag("flagA"); - ASSERT_TRUE(fetched_a); - EXPECT_EQ(1u, fetched_a->version); - - auto fetched_b = store.GetFlag("flagB"); - ASSERT_TRUE(fetched_b); - EXPECT_EQ(2u, fetched_b->version); - - ASSERT_EQ(1u, result.flags.size()); - EXPECT_EQ(1u, result.flags.count("flagB")); - EXPECT_TRUE(result.segments.empty()); -} - -TEST(MemoryStoreApplyTest, ApplyPartial_PreservesUnchangedSegments) { - MemoryStore store; Segment seg_a; seg_a.version = 1; seg_a.key = "segA"; @@ -506,31 +384,40 @@ TEST(MemoryStoreApplyTest, ApplyPartial_PreservesUnchangedSegments) { seg_b.key = "segB"; store.Init(SDKDataSet{ - std::unordered_map(), + std::unordered_map{ + {"flagA", FlagDescriptor(flag_a)}, + {"flagB", FlagDescriptor(flag_b)}}, std::unordered_map{ {"segA", SegmentDescriptor(seg_a)}, {"segB", SegmentDescriptor(seg_b)}}, }); + Flag flag_b_new; + flag_b_new.version = 2; + flag_b_new.key = "flagB"; + Segment seg_b_new; seg_b_new.version = 2; seg_b_new.key = "segB"; auto result = store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kPartial, - std::vector{{"segB", SegmentDescriptor(seg_b_new)}}, + std::vector{{"flagB", FlagDescriptor(flag_b_new)}, + {"segB", SegmentDescriptor(seg_b_new)}}, Selector{}, }); - auto fetched_a = store.GetSegment("segA"); - ASSERT_TRUE(fetched_a); - EXPECT_EQ(1u, fetched_a->version); - - auto fetched_b = store.GetSegment("segB"); - ASSERT_TRUE(fetched_b); - EXPECT_EQ(2u, fetched_b->version); + ASSERT_TRUE(store.GetFlag("flagA")); + EXPECT_EQ(1u, store.GetFlag("flagA")->version); + ASSERT_TRUE(store.GetFlag("flagB")); + EXPECT_EQ(2u, store.GetFlag("flagB")->version); + ASSERT_TRUE(store.GetSegment("segA")); + EXPECT_EQ(1u, store.GetSegment("segA")->version); + ASSERT_TRUE(store.GetSegment("segB")); + EXPECT_EQ(2u, store.GetSegment("segB")->version); - EXPECT_TRUE(result.flags.empty()); + ASSERT_EQ(1u, result.flags.size()); + EXPECT_EQ(1u, result.flags.count("flagB")); ASSERT_EQ(1u, result.segments.size()); EXPECT_EQ(1u, result.segments.count("segB")); } @@ -563,34 +450,6 @@ TEST(MemoryStoreApplyTest, ApplyPartial_WithFlagTombstone) { EXPECT_TRUE(result.segments.empty()); } -TEST(MemoryStoreApplyTest, ApplyPartial_TombstoneSkippedIfVersionNotNewer) { - MemoryStore store; - Flag flag_a; - flag_a.version = 5; - flag_a.key = "flagA"; - - store.Init(SDKDataSet{ - std::unordered_map{ - {"flagA", FlagDescriptor(flag_a)}}, - std::unordered_map(), - }); - - // Tombstone at version 3 < stored version 5: should be ignored. - auto result = store.Apply(FDv2ChangeSet{ - FDv2ChangeSet::Type::kPartial, - std::vector{{"flagA", FlagDescriptor(Tombstone(3))}}, - Selector{}, - }); - - auto fetched = store.GetFlag("flagA"); - ASSERT_TRUE(fetched); - EXPECT_EQ(5u, fetched->version); - EXPECT_TRUE(fetched->item.has_value()); - - EXPECT_TRUE(result.flags.empty()); - EXPECT_TRUE(result.segments.empty()); -} - TEST(MemoryStoreApplyTest, ApplyPartial_MixedStaleAndFreshItems) { MemoryStore store; Flag flag_a; From 7826357582c1e65c305951b6c6a7a31a13c17144 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Mon, 30 Mar 2026 10:14:54 -0700 Subject: [PATCH 06/35] simplify, since FDv2 doesnt require version checking in memory store --- .../memory_store/memory_store.cpp | 42 +-- .../memory_store/memory_store.hpp | 8 +- .../tests/memory_store_apply_test.cpp | 286 +----------------- 3 files changed, 14 insertions(+), 322 deletions(-) diff --git a/libs/server-sdk/src/data_components/memory_store/memory_store.cpp b/libs/server-sdk/src/data_components/memory_store/memory_store.cpp index d8e9474af..c71acf08a 100644 --- a/libs/server-sdk/src/data_components/memory_store/memory_store.cpp +++ b/libs/server-sdk/src/data_components/memory_store/memory_store.cpp @@ -84,24 +84,15 @@ bool MemoryStore::RemoveSegment(std::string const& key) { return segments_.erase(key) == 1; } -ApplyResult MemoryStore::Apply(data_model::FDv2ChangeSet changeSet) { +void MemoryStore::Apply(data_model::FDv2ChangeSet changeSet) { std::lock_guard lock{data_mutex_}; - ApplyResult result; switch (changeSet.type) { case data_model::FDv2ChangeSet::Type::kNone: - return result; + return; case data_model::FDv2ChangeSet::Type::kPartial: break; case data_model::FDv2ChangeSet::Type::kFull: - // When there's a full change, any current keys are considered - // changed, regardless of whether they are in the new set. - for (auto const& [key, _] : flags_) { - result.flags.insert(key); - } - for (auto const& [key, _] : segments_) { - result.segments.insert(key); - } initialized_ = true; flags_.clear(); segments_.clear(); @@ -112,38 +103,15 @@ ApplyResult MemoryStore::Apply(data_model::FDv2ChangeSet changeSet) { for (auto& change : changeSet.changes) { if (std::holds_alternative(change.object)) { - auto& flag_descriptor = - std::get(change.object); - - auto existing_flag = flags_.find(change.key); - if (existing_flag != flags_.end() && - existing_flag->second->version >= flag_descriptor.version) { - continue; - } - flags_[change.key] = std::make_shared( - std::move(flag_descriptor)); - result.flags.insert(change.key); + std::move(std::get(change.object))); } else if (std::holds_alternative( change.object)) { - auto& segment_descriptor = - std::get(change.object); - - auto existing_segment = segments_.find(change.key); - if (existing_segment != segments_.end() && - existing_segment->second->version >= - segment_descriptor.version) { - continue; - } - segments_[change.key] = - std::make_shared( - std::move(segment_descriptor)); - result.segments.insert(change.key); + std::make_shared(std::move( + std::get(change.object))); } } - - return result; } } // namespace launchdarkly::server_side::data_components diff --git a/libs/server-sdk/src/data_components/memory_store/memory_store.hpp b/libs/server-sdk/src/data_components/memory_store/memory_store.hpp index 5013adcce..e9a067881 100644 --- a/libs/server-sdk/src/data_components/memory_store/memory_store.hpp +++ b/libs/server-sdk/src/data_components/memory_store/memory_store.hpp @@ -9,15 +9,9 @@ #include #include #include -#include namespace launchdarkly::server_side::data_components { -struct ApplyResult { - std::unordered_set flags; - std::unordered_set segments; -}; - class MemoryStore final : public data_interfaces::IStore, public data_interfaces::IDestination { public: @@ -52,7 +46,7 @@ class MemoryStore final : public data_interfaces::IStore, bool RemoveSegment(std::string const& key); - [[nodiscard]] ApplyResult Apply(data_model::FDv2ChangeSet changeSet); + void Apply(data_model::FDv2ChangeSet changeSet); MemoryStore() = default; ~MemoryStore() override = default; diff --git a/libs/server-sdk/tests/memory_store_apply_test.cpp b/libs/server-sdk/tests/memory_store_apply_test.cpp index 619fe380b..003285c53 100644 --- a/libs/server-sdk/tests/memory_store_apply_test.cpp +++ b/libs/server-sdk/tests/memory_store_apply_test.cpp @@ -27,8 +27,7 @@ TEST(MemoryStoreApplyTest, ApplyNone_IsNoOp) { {"segA", SegmentDescriptor(seg_a)}}, }); - auto result = - store.Apply(FDv2ChangeSet{FDv2ChangeSet::Type::kNone, {}, Selector{}}); + store.Apply(FDv2ChangeSet{FDv2ChangeSet::Type::kNone, {}, Selector{}}); auto fetched_flag = store.GetFlag("flagA"); ASSERT_TRUE(fetched_flag); @@ -36,15 +35,11 @@ TEST(MemoryStoreApplyTest, ApplyNone_IsNoOp) { auto fetched_seg = store.GetSegment("segA"); ASSERT_TRUE(fetched_seg); EXPECT_EQ(1u, fetched_seg->version); - - EXPECT_TRUE(result.flags.empty()); - EXPECT_TRUE(result.segments.empty()); } TEST(MemoryStoreApplyTest, ApplyNone_DoesNotInitialize) { MemoryStore store; - std::ignore = store.Apply( - FDv2ChangeSet{FDv2ChangeSet::Type::kNone, {}, Selector{}}); + store.Apply(FDv2ChangeSet{FDv2ChangeSet::Type::kNone, {}, Selector{}}); EXPECT_FALSE(store.Initialized()); } @@ -55,8 +50,7 @@ TEST(MemoryStoreApplyTest, ApplyNone_DoesNotInitialize) { TEST(MemoryStoreApplyTest, ApplyFull_SetsInitialized) { MemoryStore store; ASSERT_FALSE(store.Initialized()); - std::ignore = store.Apply( - FDv2ChangeSet{FDv2ChangeSet::Type::kFull, {}, Selector{}}); + store.Apply(FDv2ChangeSet{FDv2ChangeSet::Type::kFull, {}, Selector{}}); EXPECT_TRUE(store.Initialized()); } @@ -70,7 +64,7 @@ TEST(MemoryStoreApplyTest, ApplyFull_StoresItems) { seg_a.version = 1; seg_a.key = "segA"; - auto result = store.Apply(FDv2ChangeSet{ + store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kFull, std::vector{{"flagA", FlagDescriptor(flag_a)}, {"segA", SegmentDescriptor(seg_a)}}, @@ -88,11 +82,6 @@ TEST(MemoryStoreApplyTest, ApplyFull_StoresItems) { EXPECT_TRUE(fetched_seg->item.has_value()); EXPECT_EQ("segA", fetched_seg->item->key); EXPECT_EQ(1u, fetched_seg->version); - - ASSERT_EQ(1u, result.flags.size()); - EXPECT_EQ(1u, result.flags.count("flagA")); - ASSERT_EQ(1u, result.segments.size()); - EXPECT_EQ(1u, result.segments.count("segA")); } TEST(MemoryStoreApplyTest, ApplyFull_ClearsExistingItems) { @@ -125,7 +114,7 @@ TEST(MemoryStoreApplyTest, ApplyFull_ClearsExistingItems) { seg_b.version = 1; seg_b.key = "segB"; - auto result = store.Apply(FDv2ChangeSet{ + store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kFull, std::vector{{"flagC", FlagDescriptor(flag_c)}, {"segB", SegmentDescriptor(seg_b)}}, @@ -137,192 +126,13 @@ TEST(MemoryStoreApplyTest, ApplyFull_ClearsExistingItems) { ASSERT_TRUE(store.GetFlag("flagC")); EXPECT_FALSE(store.GetSegment("segA")); ASSERT_TRUE(store.GetSegment("segB")); - - // Cleared keys and new keys all reported as changed. - ASSERT_EQ(3u, result.flags.size()); - EXPECT_EQ(1u, result.flags.count("flagA")); - EXPECT_EQ(1u, result.flags.count("flagB")); - EXPECT_EQ(1u, result.flags.count("flagC")); - ASSERT_EQ(2u, result.segments.size()); - EXPECT_EQ(1u, result.segments.count("segA")); - EXPECT_EQ(1u, result.segments.count("segB")); -} - -TEST(MemoryStoreApplyTest, ApplyFull_EmptyChangeSetClearsStore) { - MemoryStore store; - Flag flag_a; - flag_a.version = 1; - flag_a.key = "flagA"; - - Segment seg_a; - seg_a.version = 1; - seg_a.key = "segA"; - - store.Init(SDKDataSet{ - std::unordered_map{ - {"flagA", FlagDescriptor(flag_a)}}, - std::unordered_map{ - {"segA", SegmentDescriptor(seg_a)}}, - }); - - auto result = - store.Apply(FDv2ChangeSet{FDv2ChangeSet::Type::kFull, {}, Selector{}}); - - EXPECT_EQ(0u, store.AllFlags().size()); - EXPECT_EQ(0u, store.AllSegments().size()); - - ASSERT_EQ(1u, result.flags.size()); - EXPECT_EQ(1u, result.flags.count("flagA")); - ASSERT_EQ(1u, result.segments.size()); - EXPECT_EQ(1u, result.segments.count("segA")); -} - -TEST(MemoryStoreApplyTest, ApplyFull_WithFlagTombstone) { - MemoryStore store; - - auto result = store.Apply(FDv2ChangeSet{ - FDv2ChangeSet::Type::kFull, - std::vector{{"flagA", FlagDescriptor(Tombstone(5))}}, - Selector{}, - }); - - auto fetched = store.GetFlag("flagA"); - ASSERT_TRUE(fetched); - EXPECT_EQ(5u, fetched->version); - EXPECT_FALSE(fetched->item.has_value()); - - ASSERT_EQ(1u, result.flags.size()); - EXPECT_EQ(1u, result.flags.count("flagA")); - EXPECT_TRUE(result.segments.empty()); } // --------------------------------------------------------------------------- // kPartial tests // --------------------------------------------------------------------------- -TEST(MemoryStoreApplyTest, ApplyPartial_UpsertsNewItems) { - MemoryStore store; - store.Init(SDKDataSet{ - std::unordered_map(), - std::unordered_map(), - }); - - Flag flag_a; - flag_a.version = 1; - flag_a.key = "flagA"; - - Segment seg_a; - seg_a.version = 1; - seg_a.key = "segA"; - - auto result = store.Apply(FDv2ChangeSet{ - FDv2ChangeSet::Type::kPartial, - std::vector{{"flagA", FlagDescriptor(flag_a)}, - {"segA", SegmentDescriptor(seg_a)}}, - Selector{}, - }); - - auto fetched_flag = store.GetFlag("flagA"); - ASSERT_TRUE(fetched_flag); - EXPECT_TRUE(fetched_flag->item.has_value()); - EXPECT_EQ("flagA", fetched_flag->item->key); - EXPECT_EQ(1u, fetched_flag->version); - - auto fetched_seg = store.GetSegment("segA"); - ASSERT_TRUE(fetched_seg); - EXPECT_TRUE(fetched_seg->item.has_value()); - EXPECT_EQ("segA", fetched_seg->item->key); - EXPECT_EQ(1u, fetched_seg->version); - - ASSERT_EQ(1u, result.flags.size()); - EXPECT_EQ(1u, result.flags.count("flagA")); - ASSERT_EQ(1u, result.segments.size()); - EXPECT_EQ(1u, result.segments.count("segA")); -} - -TEST(MemoryStoreApplyTest, ApplyPartial_SkipsStaleItems) { - MemoryStore store; - Flag flag_a; - flag_a.version = 5; - flag_a.key = "flagA"; - - Segment seg_a; - seg_a.version = 5; - seg_a.key = "segA"; - - store.Init(SDKDataSet{ - std::unordered_map{ - {"flagA", FlagDescriptor(flag_a)}}, - std::unordered_map{ - {"segA", SegmentDescriptor(seg_a)}}, - }); - - Flag flag_a_stale; - flag_a_stale.version = 3; - flag_a_stale.key = "flagA"; - - Segment seg_a_stale; - seg_a_stale.version = 3; - seg_a_stale.key = "segA"; - - auto result = store.Apply(FDv2ChangeSet{ - FDv2ChangeSet::Type::kPartial, - std::vector{{"flagA", FlagDescriptor(flag_a_stale)}, - {"segA", SegmentDescriptor(seg_a_stale)}}, - Selector{}, - }); - - ASSERT_TRUE(store.GetFlag("flagA")); - EXPECT_EQ(5u, store.GetFlag("flagA")->version); - ASSERT_TRUE(store.GetSegment("segA")); - EXPECT_EQ(5u, store.GetSegment("segA")->version); - - EXPECT_TRUE(result.flags.empty()); - EXPECT_TRUE(result.segments.empty()); -} - -TEST(MemoryStoreApplyTest, ApplyPartial_SkipsItemsWithEqualVersion) { - MemoryStore store; - Flag flag_a; - flag_a.version = 5; - flag_a.key = "flagA"; - - Segment seg_a; - seg_a.version = 5; - seg_a.key = "segA"; - - store.Init(SDKDataSet{ - std::unordered_map{ - {"flagA", FlagDescriptor(flag_a)}}, - std::unordered_map{ - {"segA", SegmentDescriptor(seg_a)}}, - }); - - Flag flag_a_same; - flag_a_same.version = 5; - flag_a_same.key = "flagA"; - - Segment seg_a_same; - seg_a_same.version = 5; - seg_a_same.key = "segA"; - - auto result = store.Apply(FDv2ChangeSet{ - FDv2ChangeSet::Type::kPartial, - std::vector{{"flagA", FlagDescriptor(flag_a_same)}, - {"segA", SegmentDescriptor(seg_a_same)}}, - Selector{}, - }); - - ASSERT_TRUE(store.GetFlag("flagA")); - EXPECT_EQ(5u, store.GetFlag("flagA")->version); - ASSERT_TRUE(store.GetSegment("segA")); - EXPECT_EQ(5u, store.GetSegment("segA")->version); - - EXPECT_TRUE(result.flags.empty()); - EXPECT_TRUE(result.segments.empty()); -} - -TEST(MemoryStoreApplyTest, ApplyPartial_AppliesFreshItems) { +TEST(MemoryStoreApplyTest, ApplyPartial_AppliesItems) { MemoryStore store; Flag flag_a; flag_a.version = 5; @@ -347,7 +157,7 @@ TEST(MemoryStoreApplyTest, ApplyPartial_AppliesFreshItems) { seg_a_new.version = 6; seg_a_new.key = "segA"; - auto result = store.Apply(FDv2ChangeSet{ + store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kPartial, std::vector{{"flagA", FlagDescriptor(flag_a_new)}, {"segA", SegmentDescriptor(seg_a_new)}}, @@ -358,11 +168,6 @@ TEST(MemoryStoreApplyTest, ApplyPartial_AppliesFreshItems) { EXPECT_EQ(6u, store.GetFlag("flagA")->version); ASSERT_TRUE(store.GetSegment("segA")); EXPECT_EQ(6u, store.GetSegment("segA")->version); - - ASSERT_EQ(1u, result.flags.size()); - EXPECT_EQ(1u, result.flags.count("flagA")); - ASSERT_EQ(1u, result.segments.size()); - EXPECT_EQ(1u, result.segments.count("segA")); } TEST(MemoryStoreApplyTest, ApplyPartial_PreservesUnchangedItems) { @@ -400,7 +205,7 @@ TEST(MemoryStoreApplyTest, ApplyPartial_PreservesUnchangedItems) { seg_b_new.version = 2; seg_b_new.key = "segB"; - auto result = store.Apply(FDv2ChangeSet{ + store.Apply(FDv2ChangeSet{ FDv2ChangeSet::Type::kPartial, std::vector{{"flagB", FlagDescriptor(flag_b_new)}, {"segB", SegmentDescriptor(seg_b_new)}}, @@ -415,79 +220,4 @@ TEST(MemoryStoreApplyTest, ApplyPartial_PreservesUnchangedItems) { EXPECT_EQ(1u, store.GetSegment("segA")->version); ASSERT_TRUE(store.GetSegment("segB")); EXPECT_EQ(2u, store.GetSegment("segB")->version); - - ASSERT_EQ(1u, result.flags.size()); - EXPECT_EQ(1u, result.flags.count("flagB")); - ASSERT_EQ(1u, result.segments.size()); - EXPECT_EQ(1u, result.segments.count("segB")); -} - -TEST(MemoryStoreApplyTest, ApplyPartial_WithFlagTombstone) { - MemoryStore store; - Flag flag_a; - flag_a.version = 1; - flag_a.key = "flagA"; - - store.Init(SDKDataSet{ - std::unordered_map{ - {"flagA", FlagDescriptor(flag_a)}}, - std::unordered_map(), - }); - - auto result = store.Apply(FDv2ChangeSet{ - FDv2ChangeSet::Type::kPartial, - std::vector{{"flagA", FlagDescriptor(Tombstone(2))}}, - Selector{}, - }); - - auto fetched = store.GetFlag("flagA"); - ASSERT_TRUE(fetched); - EXPECT_EQ(2u, fetched->version); - EXPECT_FALSE(fetched->item.has_value()); - - ASSERT_EQ(1u, result.flags.size()); - EXPECT_EQ(1u, result.flags.count("flagA")); - EXPECT_TRUE(result.segments.empty()); -} - -TEST(MemoryStoreApplyTest, ApplyPartial_MixedStaleAndFreshItems) { - MemoryStore store; - Flag flag_a; - flag_a.version = 10; - flag_a.key = "flagA"; - - Flag flag_b; - flag_b.version = 1; - flag_b.key = "flagB"; - - store.Init(SDKDataSet{ - std::unordered_map{ - {"flagA", FlagDescriptor(flag_a)}, - {"flagB", FlagDescriptor(flag_b)}}, - std::unordered_map(), - }); - - Flag flag_a_stale; - flag_a_stale.version = 5; - flag_a_stale.key = "flagA"; - - Flag flag_b_new; - flag_b_new.version = 2; - flag_b_new.key = "flagB"; - - auto result = store.Apply(FDv2ChangeSet{ - FDv2ChangeSet::Type::kPartial, - std::vector{{"flagA", FlagDescriptor(flag_a_stale)}, - {"flagB", FlagDescriptor(flag_b_new)}}, - Selector{}, - }); - - // flagA version 5 < 10: skip. - EXPECT_EQ(10u, store.GetFlag("flagA")->version); - // flagB version 2 > 1: apply. - EXPECT_EQ(2u, store.GetFlag("flagB")->version); - - ASSERT_EQ(1u, result.flags.size()); - EXPECT_EQ(1u, result.flags.count("flagB")); - EXPECT_TRUE(result.segments.empty()); } From 651a780e8dddf04e13065f741706c51b13feb17d Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Mon, 30 Mar 2026 17:27:55 -0700 Subject: [PATCH 07/35] refactor: define new Synchronizer and Initializer interfaces for FDv2 --- .../source/fdv2_source_result.hpp | 65 +++++++++++++++++++ .../source/ifdv2_initializer.hpp | 46 +++++++++++++ .../source/ifdv2_synchronizer.hpp | 57 ++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 libs/server-sdk/src/data_interfaces/source/fdv2_source_result.hpp create mode 100644 libs/server-sdk/src/data_interfaces/source/ifdv2_initializer.hpp create mode 100644 libs/server-sdk/src/data_interfaces/source/ifdv2_synchronizer.hpp diff --git a/libs/server-sdk/src/data_interfaces/source/fdv2_source_result.hpp b/libs/server-sdk/src/data_interfaces/source/fdv2_source_result.hpp new file mode 100644 index 000000000..2d7dd435b --- /dev/null +++ b/libs/server-sdk/src/data_interfaces/source/fdv2_source_result.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include +#include + +#include +#include +#include + +namespace launchdarkly::server_side::data_interfaces { + +/** + * Result returned by IFDv2Initializer::Run and IFDv2Synchronizer::Next. + * + * Mirrors Java's FDv2SourceResult. + */ +struct FDv2SourceResult { + using ErrorInfo = common::data_sources::DataSourceStatusErrorInfo; + + /** + * A changeset was successfully received and is ready to apply. + */ + struct ChangeSet { + data_model::FDv2ChangeSet change_set; + /** If true, the server signaled that the client should fall back to + * FDv1. */ + bool fdv1_fallback; + }; + + /** + * A transient error occurred; the source may recover. + */ + struct Interrupted { + ErrorInfo error; + bool fdv1_fallback; + }; + + /** + * A non-recoverable error occurred; the source should not be retried. + */ + struct TerminalError { + ErrorInfo error; + bool fdv1_fallback; + }; + + /** + * The source was closed cleanly (via Close()). + */ + struct Shutdown {}; + + /** + * The server sent a goodbye; the orchestrator should rotate sources. + */ + struct Goodbye { + std::optional reason; + bool fdv1_fallback; + }; + + using Value = + std::variant; + + Value value; +}; + +} // namespace launchdarkly::server_side::data_interfaces diff --git a/libs/server-sdk/src/data_interfaces/source/ifdv2_initializer.hpp b/libs/server-sdk/src/data_interfaces/source/ifdv2_initializer.hpp new file mode 100644 index 000000000..5c2608cd1 --- /dev/null +++ b/libs/server-sdk/src/data_interfaces/source/ifdv2_initializer.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include "fdv2_source_result.hpp" + +#include + +namespace launchdarkly::server_side::data_interfaces { + +/** + * Defines a one-shot data source that runs to completion and returns a single + * result. Used during the initialization phase of FDv2, before handing off to + * an IFDv2Synchronizer. + */ +class IFDv2Initializer { + public: + /** + * Run the initializer to completion. Blocks until a result is available. + * Called at most once per instance. + * + * Close() may be called from another thread to unblock Run(), in which + * case Run() returns FDv2SourceResult::Shutdown. + */ + virtual FDv2SourceResult Run() = 0; + + /** + * Unblocks any in-progress Run() call, causing it to return + * FDv2SourceResult::Shutdown. + */ + virtual void Close() = 0; + + /** + * @return A display-suitable name of the initializer. + */ + [[nodiscard]] virtual std::string const& Identity() const = 0; + + virtual ~IFDv2Initializer() = default; + IFDv2Initializer(IFDv2Initializer const&) = delete; + IFDv2Initializer(IFDv2Initializer&&) = delete; + IFDv2Initializer& operator=(IFDv2Initializer const&) = delete; + IFDv2Initializer& operator=(IFDv2Initializer&&) = delete; + + protected: + IFDv2Initializer() = default; +}; + +} // namespace launchdarkly::server_side::data_interfaces diff --git a/libs/server-sdk/src/data_interfaces/source/ifdv2_synchronizer.hpp b/libs/server-sdk/src/data_interfaces/source/ifdv2_synchronizer.hpp new file mode 100644 index 000000000..4ba11872d --- /dev/null +++ b/libs/server-sdk/src/data_interfaces/source/ifdv2_synchronizer.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include "fdv2_source_result.hpp" + +#include +#include + +namespace launchdarkly::server_side::data_interfaces { + +/** + * Defines a continuous data source that produces a stream of results. Used + * during the synchronization phase of FDv2, after initialization is complete. + * + * The stream is started lazily on the first call to Next(). The synchronizer + * runs until Close() is called. + */ +class IFDv2Synchronizer { + public: + /** + * Block until the next result is available or the timeout expires. + * + * On the first call, the synchronizer starts its underlying connection. + * Subsequent calls continue reading from the same connection. + * + * If the timeout expires before a result arrives, returns + * FDv2SourceResult::Interrupted. The orchestrator uses this to evaluate + * fallback conditions. + * + * Close() may be called from another thread to unblock Next(), in which + * case Next() returns FDv2SourceResult::Shutdown. + * + * @param timeout Maximum time to wait for the next result. + */ + virtual FDv2SourceResult Next(std::chrono::milliseconds timeout) = 0; + + /** + * Unblocks any in-progress Next() call, causing it to return + * FDv2SourceResult::Shutdown, and releases underlying resources. + */ + virtual void Close() = 0; + + /** + * @return A display-suitable name of the synchronizer. + */ + [[nodiscard]] virtual std::string const& Identity() const = 0; + + virtual ~IFDv2Synchronizer() = default; + IFDv2Synchronizer(IFDv2Synchronizer const&) = delete; + IFDv2Synchronizer(IFDv2Synchronizer&&) = delete; + IFDv2Synchronizer& operator=(IFDv2Synchronizer const&) = delete; + IFDv2Synchronizer& operator=(IFDv2Synchronizer&&) = delete; + + protected: + IFDv2Synchronizer() = default; +}; + +} // namespace launchdarkly::server_side::data_interfaces From fe37f5571e03fe214be497a8be5e07bd1bcfd0d7 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 2 Apr 2026 10:42:20 -0700 Subject: [PATCH 08/35] move selector source into Next --- .gitignore | 1 + .../src/data_interfaces/source/ifdv2_synchronizer.hpp | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 76143cd10..0dac51100 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ build-dynamic build-static-debug build-dynamic-debug cmake-build-* +.cache # For Macs.. .DS_Store diff --git a/libs/server-sdk/src/data_interfaces/source/ifdv2_synchronizer.hpp b/libs/server-sdk/src/data_interfaces/source/ifdv2_synchronizer.hpp index 4ba11872d..9a555d24e 100644 --- a/libs/server-sdk/src/data_interfaces/source/ifdv2_synchronizer.hpp +++ b/libs/server-sdk/src/data_interfaces/source/ifdv2_synchronizer.hpp @@ -2,6 +2,8 @@ #include "fdv2_source_result.hpp" +#include + #include #include @@ -29,9 +31,12 @@ class IFDv2Synchronizer { * Close() may be called from another thread to unblock Next(), in which * case Next() returns FDv2SourceResult::Shutdown. * - * @param timeout Maximum time to wait for the next result. + * @param timeout Maximum time to wait for the next result. + * @param selector The selector to send with the request, reflecting any + * changesets applied since the previous call. */ - virtual FDv2SourceResult Next(std::chrono::milliseconds timeout) = 0; + virtual FDv2SourceResult Next(std::chrono::milliseconds timeout, + data_model::Selector selector) = 0; /** * Unblocks any in-progress Next() call, causing it to return From ed228574d21d1f3e8b970448c7b412f164e4bc8b Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 2 Apr 2026 11:40:30 -0700 Subject: [PATCH 09/35] distinguish between timeouts and errors --- .../src/data_interfaces/source/fdv2_source_result.hpp | 9 +++++++-- .../src/data_interfaces/source/ifdv2_synchronizer.hpp | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/libs/server-sdk/src/data_interfaces/source/fdv2_source_result.hpp b/libs/server-sdk/src/data_interfaces/source/fdv2_source_result.hpp index 2d7dd435b..069fe5a38 100644 --- a/libs/server-sdk/src/data_interfaces/source/fdv2_source_result.hpp +++ b/libs/server-sdk/src/data_interfaces/source/fdv2_source_result.hpp @@ -56,8 +56,13 @@ struct FDv2SourceResult { bool fdv1_fallback; }; - using Value = - std::variant; + /** + * Next() returned because the timeout expired before a result arrived. + */ + struct Timeout {}; + + using Value = std::variant; Value value; }; diff --git a/libs/server-sdk/src/data_interfaces/source/ifdv2_synchronizer.hpp b/libs/server-sdk/src/data_interfaces/source/ifdv2_synchronizer.hpp index 9a555d24e..a56bacee3 100644 --- a/libs/server-sdk/src/data_interfaces/source/ifdv2_synchronizer.hpp +++ b/libs/server-sdk/src/data_interfaces/source/ifdv2_synchronizer.hpp @@ -25,7 +25,7 @@ class IFDv2Synchronizer { * Subsequent calls continue reading from the same connection. * * If the timeout expires before a result arrives, returns - * FDv2SourceResult::Interrupted. The orchestrator uses this to evaluate + * FDv2SourceResult::Timeout. The orchestrator uses this to evaluate * fallback conditions. * * Close() may be called from another thread to unblock Next(), in which From f0013dac3ee12070944582fd7d1f0b75416a961d Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 2 Apr 2026 10:32:30 -0700 Subject: [PATCH 10/35] refactor: implement fdv2 polling initializer / synchronizer --- .../launchdarkly/fdv2_protocol_handler.hpp | 62 ++++ libs/internal/src/CMakeLists.txt | 1 + libs/internal/src/fdv2_protocol_handler.cpp | 220 +++++++++++++ .../tests/fdv2_protocol_handler_test.cpp | 277 ++++++++++++++++ libs/server-sdk/src/CMakeLists.txt | 4 + .../data_systems/fdv2/polling_initializer.cpp | 254 +++++++++++++++ .../data_systems/fdv2/polling_initializer.hpp | 56 ++++ .../fdv2/polling_synchronizer.cpp | 295 ++++++++++++++++++ .../fdv2/polling_synchronizer.hpp | 71 +++++ 9 files changed, 1240 insertions(+) create mode 100644 libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp create mode 100644 libs/internal/src/fdv2_protocol_handler.cpp create mode 100644 libs/internal/tests/fdv2_protocol_handler_test.cpp create mode 100644 libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp create mode 100644 libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp create mode 100644 libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp create mode 100644 libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp diff --git a/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp b/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp new file mode 100644 index 000000000..140d38dfc --- /dev/null +++ b/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include +#include + +#include + +#include +#include +#include + +namespace launchdarkly { + +/** + * Protocol state machine for the FDv2 wire format. + * + * Accumulates put-object and delete-object events between a server-intent + * and payload-transferred event, then emits a complete FDv2ChangeSet. + * + * Shared between the polling and streaming synchronizers. + */ +class FDv2ProtocolHandler { + public: + /** + * Result of handling a single FDv2 event: + * - monostate: no output yet (accumulating, heartbeat, or unknown event) + * - FDv2ChangeSet: complete changeset ready to apply + * - FDv2Error: server reported an error; discard partial data + * - Goodbye: server is closing; caller should rotate sources + */ + using Result = std::variant; + + /** + * Process one FDv2 event. + * + * @param event_type The event type string (e.g. "server-intent", + * "put-object", "payload-transferred"). + * @param data The parsed JSON value for the event's data field. + * @return A Result indicating what (if anything) the caller + * should act on. + */ + Result HandleEvent(std::string_view event_type, + boost::json::value const& data); + + /** + * Reset accumulated state. Call on reconnect before processing new events. + */ + void Reset(); + + FDv2ProtocolHandler() = default; + + private: + enum class State { kInactive, kFull, kPartial }; + + State state_ = State::kInactive; + std::vector changes_; +}; + +} // namespace launchdarkly diff --git a/libs/internal/src/CMakeLists.txt b/libs/internal/src/CMakeLists.txt index 44600638b..8ff998499 100644 --- a/libs/internal/src/CMakeLists.txt +++ b/libs/internal/src/CMakeLists.txt @@ -35,6 +35,7 @@ set(INTERNAL_SOURCES serialization/value_mapping.cpp serialization/json_evaluation_result.cpp serialization/json_fdv2_events.cpp + fdv2_protocol_handler.cpp serialization/json_sdk_data_set.cpp serialization/json_segment.cpp serialization/json_primitives.cpp diff --git a/libs/internal/src/fdv2_protocol_handler.cpp b/libs/internal/src/fdv2_protocol_handler.cpp new file mode 100644 index 000000000..0e6b7380c --- /dev/null +++ b/libs/internal/src/fdv2_protocol_handler.cpp @@ -0,0 +1,220 @@ +#include + +#include +#include +#include +#include +#include + +#include +#include + +namespace launchdarkly { + +static char const* const kServerIntent = "server-intent"; +static char const* const kPutObject = "put-object"; +static char const* const kDeleteObject = "delete-object"; +static char const* const kPayloadTransferred = "payload-transferred"; +static char const* const kError = "error"; +static char const* const kGoodbye = "goodbye"; + +// Returns the parsed FDv2Change on success, nullopt for unknown kinds (which +// should be silently skipped for forward-compatibility), or an error string if +// a known kind fails to deserialize. +static tl::expected, std::string> +ParsePut(PutObject const& put) { + if (put.kind == "flag") { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + put.object); + // One bad flag aborts the entire transfer so the store is never + // left in a partially-updated state. + if (!result) { + return tl::make_unexpected("could not deserialize flag '" + + put.key + "'"); + } + if (!result->has_value()) { + return tl::make_unexpected("flag '" + put.key + "' object was null"); + } + return data_model::FDv2Change{ + put.key, + data_model::ItemDescriptor{std::move(**result)}}; + } + if (put.kind == "segment") { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + put.object); + // One bad segment aborts the entire transfer so the store is never + // left in a partially-updated state. + if (!result) { + return tl::make_unexpected("could not deserialize segment '" + + put.key + "'"); + } + if (!result->has_value()) { + return tl::make_unexpected("segment '" + put.key + + "' object was null"); + } + return data_model::FDv2Change{ + put.key, + data_model::ItemDescriptor{ + std::move(**result)}}; + } + // Silently skip unknown kinds for forward-compatibility. + return std::nullopt; +} + +static data_model::FDv2Change MakeDeleteChange(DeleteObject const& del) { + if (del.kind == "flag") { + return data_model::FDv2Change{ + del.key, + data_model::ItemDescriptor{ + data_model::Tombstone{static_cast(del.version)}}}; + } + return data_model::FDv2Change{ + del.key, + data_model::ItemDescriptor{ + data_model::Tombstone{static_cast(del.version)}}}; +} + +FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleEvent( + std::string_view event_type, + boost::json::value const& data) { + if (event_type == kServerIntent) { + auto result = boost::json::value_to< + tl::expected, JsonError>>(data); + if (!result) { + Reset(); + return FDv2Error{std::nullopt, "could not deserialize server-intent"}; + } + if (!result->has_value()) { + Reset(); + return FDv2Error{std::nullopt, "server-intent data was null"}; + } + auto const& intent = **result; + if (intent.payloads.empty()) { + return std::monostate{}; + } + auto const& code = intent.payloads[0].intent_code; + changes_.clear(); + if (code == IntentCode::kTransferFull) { + state_ = State::kFull; + } else if (code == IntentCode::kTransferChanges) { + state_ = State::kPartial; + } else { + // kNone or kUnknown: emit an empty changeset immediately. + state_ = State::kInactive; + return data_model::FDv2ChangeSet{data_model::FDv2ChangeSet::Type::kNone, + {}, + data_model::Selector{}}; + } + return std::monostate{}; + } + + if (event_type == kPutObject) { + if (state_ == State::kInactive) { + return std::monostate{}; + } + auto result = boost::json::value_to< + tl::expected, JsonError>>(data); + if (!result) { + Reset(); + return FDv2Error{std::nullopt, "could not deserialize put-object"}; + } + if (!result->has_value()) { + Reset(); + return FDv2Error{std::nullopt, "put-object data was null"}; + } + auto change = ParsePut(**result); + if (!change) { + Reset(); + return FDv2Error{std::nullopt, std::move(change.error())}; + } + if (*change) { + changes_.push_back(std::move(**change)); + } + return std::monostate{}; + } + + if (event_type == kDeleteObject) { + if (state_ == State::kInactive) { + return std::monostate{}; + } + auto result = boost::json::value_to< + tl::expected, JsonError>>(data); + if (!result) { + Reset(); + return FDv2Error{std::nullopt, "could not deserialize delete-object"}; + } + if (!result->has_value()) { + Reset(); + return FDv2Error{std::nullopt, "delete-object data was null"}; + } + auto const& del = **result; + // Silently skip unknown kinds for forward-compatibility. + if (del.kind != "flag" && del.kind != "segment") { + return std::monostate{}; + } + changes_.push_back(MakeDeleteChange(del)); + return std::monostate{}; + } + + if (event_type == kPayloadTransferred) { + auto result = boost::json::value_to< + tl::expected, JsonError>>(data); + if (!result) { + Reset(); + return FDv2Error{std::nullopt, + "could not deserialize payload-transferred"}; + } + if (!result->has_value()) { + Reset(); + return FDv2Error{std::nullopt, "payload-transferred data was null"}; + } + auto const& transferred = **result; + auto type = (state_ == State::kPartial) + ? data_model::FDv2ChangeSet::Type::kPartial + : data_model::FDv2ChangeSet::Type::kFull; + data_model::FDv2ChangeSet changeset{ + type, + std::move(changes_), + data_model::Selector{data_model::Selector::State{ + transferred.version, transferred.state}}}; + Reset(); + return changeset; + } + + if (event_type == kError) { + auto result = boost::json::value_to< + tl::expected, JsonError>>(data); + Reset(); + if (!result) { + return FDv2Error{std::nullopt, "could not deserialize error event"}; + } + if (!result->has_value()) { + return FDv2Error{std::nullopt, "error event data was null"}; + } + return **result; + } + + if (event_type == kGoodbye) { + auto result = boost::json::value_to< + tl::expected, JsonError>>(data); + if (!result) { + return Goodbye{std::nullopt}; + } + if (!result->has_value()) { + return Goodbye{std::nullopt}; + } + return **result; + } + + // heartbeat and unrecognized events: no-op. + return std::monostate{}; +} + +void FDv2ProtocolHandler::Reset() { + state_ = State::kInactive; + changes_.clear(); +} + +} // namespace launchdarkly diff --git a/libs/internal/tests/fdv2_protocol_handler_test.cpp b/libs/internal/tests/fdv2_protocol_handler_test.cpp new file mode 100644 index 000000000..b6b12087e --- /dev/null +++ b/libs/internal/tests/fdv2_protocol_handler_test.cpp @@ -0,0 +1,277 @@ +#include + +#include + +#include + +using namespace launchdarkly; + +// Minimal valid flag JSON accepted by the existing Flag deserializer. +static char const* const kFlagJson = + R"({"key":"my-flag","on":true,"fallthrough":{"variation":0},)" + R"("variations":[true,false],"version":1})"; + +// Minimal valid segment JSON accepted by the existing Segment deserializer. +static char const* const kSegmentJson = + R"({"key":"my-seg","version":2,"rules":[],"included":[],"excluded":[]})"; + +// Build a server-intent event data value. +static boost::json::value MakeServerIntent(std::string const& intent_code) { + return boost::json::parse( + R"({"payloads":[{"id":"p1","target":1,"intentCode":")" + intent_code + + R"("}]})"); +} + +static boost::json::value MakePutObject(std::string const& kind, + std::string const& key, + std::string const& object_json) { + return boost::json::parse(R"({"version":1,"kind":")" + kind + + R"(","key":")" + key + + R"(","object":)" + object_json + "}"); +} + +static boost::json::value MakeDeleteObject(std::string const& kind, + std::string const& key, + int version) { + return boost::json::parse(R"({"version":)" + std::to_string(version) + + R"(,"kind":")" + kind + R"(","key":")" + key + + R"("})"); +} + +static boost::json::value MakePayloadTransferred(std::string const& state, + int version) { + return boost::json::parse(R"({"state":")" + state + R"(","version":)" + + std::to_string(version) + "}"); +} + +// ============================================================================ +// kNone intent +// ============================================================================ + +TEST(FDv2ProtocolHandlerTest, NoneIntentEmitsEmptyChangeSetImmediately) { + FDv2ProtocolHandler handler; + + auto result = handler.HandleEvent("server-intent", MakeServerIntent("none")); + + auto* cs = std::get_if(&result); + ASSERT_NE(cs, nullptr); + EXPECT_EQ(cs->type, data_model::FDv2ChangeSet::Type::kNone); + EXPECT_TRUE(cs->changes.empty()); + EXPECT_FALSE(cs->selector.value.has_value()); +} + +// ============================================================================ +// kTransferFull intent +// ============================================================================ + +TEST(FDv2ProtocolHandlerTest, FullIntentEmitsChangeSetOnPayloadTransferred) { + FDv2ProtocolHandler handler; + + auto r1 = handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); + EXPECT_TRUE(std::holds_alternative(r1)); + + auto r2 = handler.HandleEvent( + "put-object", MakePutObject("flag", "my-flag", kFlagJson)); + EXPECT_TRUE(std::holds_alternative(r2)); + + auto r3 = handler.HandleEvent( + "payload-transferred", MakePayloadTransferred("state-abc", 7)); + + auto* cs = std::get_if(&r3); + ASSERT_NE(cs, nullptr); + EXPECT_EQ(cs->type, data_model::FDv2ChangeSet::Type::kFull); + EXPECT_EQ(cs->changes.size(), 1u); + EXPECT_EQ(cs->changes[0].key, "my-flag"); + ASSERT_TRUE(cs->selector.value.has_value()); + EXPECT_EQ(cs->selector.value->state, "state-abc"); + EXPECT_EQ(cs->selector.value->version, 7); +} + +TEST(FDv2ProtocolHandlerTest, FullIntentAccumulatesMultipleObjects) { + FDv2ProtocolHandler handler; + + handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); + handler.HandleEvent("put-object", + MakePutObject("flag", "flag-1", kFlagJson)); + handler.HandleEvent("put-object", + MakePutObject("flag", "flag-2", kFlagJson)); + handler.HandleEvent("delete-object", MakeDeleteObject("segment", "seg-1", 5)); + + auto result = handler.HandleEvent( + "payload-transferred", MakePayloadTransferred("s", 1)); + + auto* cs = std::get_if(&result); + ASSERT_NE(cs, nullptr); + EXPECT_EQ(cs->type, data_model::FDv2ChangeSet::Type::kFull); + EXPECT_EQ(cs->changes.size(), 3u); +} + +// ============================================================================ +// kTransferChanges intent +// ============================================================================ + +TEST(FDv2ProtocolHandlerTest, PartialIntentEmitsPartialChangeSet) { + FDv2ProtocolHandler handler; + + handler.HandleEvent("server-intent", MakeServerIntent("xfer-changes")); + handler.HandleEvent("put-object", + MakePutObject("segment", "my-seg", kSegmentJson)); + + auto result = handler.HandleEvent( + "payload-transferred", MakePayloadTransferred("state-xyz", 3)); + + auto* cs = std::get_if(&result); + ASSERT_NE(cs, nullptr); + EXPECT_EQ(cs->type, data_model::FDv2ChangeSet::Type::kPartial); + EXPECT_EQ(cs->changes.size(), 1u); + EXPECT_EQ(cs->changes[0].key, "my-seg"); + ASSERT_TRUE(cs->selector.value.has_value()); + EXPECT_EQ(cs->selector.value->state, "state-xyz"); +} + +// ============================================================================ +// Unknown kind in put-object → silently skipped +// ============================================================================ + +TEST(FDv2ProtocolHandlerTest, UnknownKindInPutObjectIsSilentlySkipped) { + FDv2ProtocolHandler handler; + + handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); + handler.HandleEvent("put-object", + MakePutObject("experiment", "exp-1", R"({"key":"exp-1","version":1})")); + handler.HandleEvent("put-object", + MakePutObject("flag", "my-flag", kFlagJson)); + + auto result = handler.HandleEvent( + "payload-transferred", MakePayloadTransferred("s", 1)); + + auto* cs = std::get_if(&result); + ASSERT_NE(cs, nullptr); + // Only the known kind (flag) should appear. + EXPECT_EQ(cs->changes.size(), 1u); + EXPECT_EQ(cs->changes[0].key, "my-flag"); +} + +// ============================================================================ +// error event → discard accumulated data, return FDv2Error +// ============================================================================ + +TEST(FDv2ProtocolHandlerTest, ErrorEventDiscardsAccumulatedDataAndReturnsError) { + FDv2ProtocolHandler handler; + + handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); + handler.HandleEvent("put-object", + MakePutObject("flag", "my-flag", kFlagJson)); + + auto result = handler.HandleEvent( + "error", + boost::json::parse(R"({"reason":"something went wrong"})")); + + auto* err = std::get_if(&result); + ASSERT_NE(err, nullptr); + EXPECT_EQ(err->reason, "something went wrong"); + + // After the error the handler is reset. A subsequent full transfer should + // produce an empty changeset (no leftover data from before the error). + handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); + auto result2 = handler.HandleEvent( + "payload-transferred", MakePayloadTransferred("s", 1)); + + auto* cs = std::get_if(&result2); + ASSERT_NE(cs, nullptr); + EXPECT_TRUE(cs->changes.empty()); +} + +// ============================================================================ +// goodbye event → return Goodbye +// ============================================================================ + +TEST(FDv2ProtocolHandlerTest, GoodbyeEventReturnsGoodbye) { + FDv2ProtocolHandler handler; + + auto result = handler.HandleEvent( + "goodbye", + boost::json::parse(R"({"reason":"shutting down"})")); + + auto* gb = std::get_if(&result); + ASSERT_NE(gb, nullptr); + ASSERT_TRUE(gb->reason.has_value()); + EXPECT_EQ(*gb->reason, "shutting down"); +} + +TEST(FDv2ProtocolHandlerTest, GoodbyeWithoutReasonReturnsGoodbye) { + FDv2ProtocolHandler handler; + + auto result = handler.HandleEvent("goodbye", boost::json::parse(R"({})")); + + auto* gb = std::get_if(&result); + ASSERT_NE(gb, nullptr); + EXPECT_FALSE(gb->reason.has_value()); +} + +// ============================================================================ +// heartbeat → no-op +// ============================================================================ + +TEST(FDv2ProtocolHandlerTest, HeartbeatReturnsMonostate) { + FDv2ProtocolHandler handler; + + auto result = + handler.HandleEvent("heartbeat", boost::json::parse(R"({})")); + EXPECT_TRUE(std::holds_alternative(result)); +} + +// ============================================================================ +// Unrecognized event type → no-op +// ============================================================================ + +TEST(FDv2ProtocolHandlerTest, UnknownEventTypeReturnsMonostate) { + FDv2ProtocolHandler handler; + + auto result = + handler.HandleEvent("future-event-type", boost::json::parse(R"({})")); + EXPECT_TRUE(std::holds_alternative(result)); +} + +// ============================================================================ +// put-object and delete-object before server-intent are ignored +// ============================================================================ + +TEST(FDv2ProtocolHandlerTest, PutBeforeServerIntentIsIgnored) { + FDv2ProtocolHandler handler; + + auto r1 = handler.HandleEvent("put-object", + MakePutObject("flag", "my-flag", kFlagJson)); + EXPECT_TRUE(std::holds_alternative(r1)); + + handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); + auto result = handler.HandleEvent( + "payload-transferred", MakePayloadTransferred("s", 1)); + + auto* cs = std::get_if(&result); + ASSERT_NE(cs, nullptr); + EXPECT_TRUE(cs->changes.empty()); +} + +// ============================================================================ +// Reset clears accumulated state +// ============================================================================ + +TEST(FDv2ProtocolHandlerTest, ResetClearsState) { + FDv2ProtocolHandler handler; + + handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); + handler.HandleEvent("put-object", + MakePutObject("flag", "my-flag", kFlagJson)); + handler.Reset(); + + // After reset, payload-transferred with no prior server-intent produces + // a full changeset with no changes. + handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); + auto result = handler.HandleEvent( + "payload-transferred", MakePayloadTransferred("s", 1)); + + auto* cs = std::get_if(&result); + ASSERT_NE(cs, nullptr); + EXPECT_TRUE(cs->changes.empty()); +} diff --git a/libs/server-sdk/src/CMakeLists.txt b/libs/server-sdk/src/CMakeLists.txt index 62a017d41..2d855d575 100644 --- a/libs/server-sdk/src/CMakeLists.txt +++ b/libs/server-sdk/src/CMakeLists.txt @@ -49,6 +49,10 @@ target_sources(${LIBNAME} data_systems/background_sync/detail/payload_filter_validation/payload_filter_validation.cpp data_systems/background_sync/sources/polling/polling_data_source.hpp data_systems/background_sync/sources/polling/polling_data_source.cpp + data_systems/fdv2/polling_initializer.hpp + data_systems/fdv2/polling_initializer.cpp + data_systems/fdv2/polling_synchronizer.hpp + data_systems/fdv2/polling_synchronizer.cpp data_systems/background_sync/sources/streaming/streaming_data_source.hpp data_systems/background_sync/sources/streaming/streaming_data_source.cpp data_systems/background_sync/sources/streaming/event_handler.hpp diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp new file mode 100644 index 000000000..07593a53f --- /dev/null +++ b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp @@ -0,0 +1,254 @@ +#include "polling_initializer.hpp" + +#include +#include +#include + +#include + +#include + +namespace launchdarkly::server_side::data_systems { + +static char const* const kIdentity = "FDv2 polling initializer"; +static char const* const kFDv2PollPath = "/sdk/poll"; + +static char const* const kErrorParsingBody = + "Could not parse FDv2 polling response"; +static char const* const kErrorMissingEvents = + "FDv2 polling response missing 'events' array"; +static char const* const kErrorIncompletePayload = + "FDv2 polling response did not contain a complete payload"; + +using ErrorInfo = data_interfaces::FDv2SourceResult::ErrorInfo; +using ErrorKind = ErrorInfo::ErrorKind; + +static ErrorInfo MakeError(ErrorKind kind, + ErrorInfo::StatusCodeType status, + std::string message) { + return ErrorInfo{kind, status, std::move(message), + std::chrono::system_clock::now()}; +} + +static network::HttpRequest MakeRequest( + Logger const& logger, + config::built::ServiceEndpoints const& endpoints, + config::built::HttpProperties const& http_properties, + data_model::Selector const& selector, + std::optional const& filter_key) { + auto url = std::make_optional(endpoints.PollingBaseUrl()); + url = network::AppendUrl(url, kFDv2PollPath); + + bool has_query = false; + if (selector.value) { + url->append("?basis=" + selector.value->state); + has_query = true; + } + + if (filter_key) { + url->append(has_query ? "&filter=" : "?filter="); + url->append(*filter_key); + } + + config::builders::HttpPropertiesBuilder const builder(http_properties); + return {url.value_or(""), network::HttpMethod::kGet, builder.Build(), + network::HttpRequest::BodyType{}}; +} + +FDv2PollingInitializer::FDv2PollingInitializer( + boost::asio::any_io_executor const& executor, + Logger const& logger, + config::built::ServiceEndpoints const& endpoints, + config::built::HttpProperties const& http_properties, + data_model::Selector selector, + std::optional filter_key) + : logger_(logger), + requester_(executor, http_properties.Tls()), + request_(MakeRequest(logger, endpoints, http_properties, selector, + filter_key)) {} + +data_interfaces::FDv2SourceResult FDv2PollingInitializer::Run() { + if (!request_.Valid()) { + LD_LOG(logger_, LogLevel::kError) + << kIdentity << ": invalid polling endpoint URL"; + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::TerminalError{ + MakeError(ErrorKind::kNetworkError, 0, + "invalid polling endpoint URL"), + false}}; + } + + auto shared_result = + std::make_shared>(); + + requester_.Request(request_, [this, shared_result](network::HttpResult res) { + std::lock_guard guard(mutex_); + *shared_result = std::move(res); + cv_.notify_one(); + }); + + std::unique_lock lock(mutex_); + cv_.wait(lock, [&] { return shared_result->has_value() || closed_; }); + + if (closed_) { + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Shutdown{}}; + } + + auto http_result = std::move(**shared_result); + lock.unlock(); + + return HandlePollResult(http_result); +} + +void FDv2PollingInitializer::Close() { + std::lock_guard lock(mutex_); + closed_ = true; + cv_.notify_one(); +} + +std::string const& FDv2PollingInitializer::Identity() const { + static std::string const identity = kIdentity; + return identity; +} + +data_interfaces::FDv2SourceResult FDv2PollingInitializer::HandlePollResult( + network::HttpResult const& res) { + if (res.IsError()) { + auto const& msg = res.ErrorMessage(); + std::string error_msg = msg.has_value() ? *msg : "unknown error"; + LD_LOG(logger_, LogLevel::kWarn) + << kIdentity << ": " << error_msg; + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kNetworkError, 0, std::move(error_msg)), + false}}; + } + + if (res.Status() == 304) { + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::ChangeSet{ + data_model::FDv2ChangeSet{ + data_model::FDv2ChangeSet::Type::kNone, {}, {}}, + false}}; + } + + if (res.Status() == 200) { + auto const& body = res.Body(); + if (!body) { + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, + "polling response contained no body"), + false}}; + } + + boost::system::error_code ec; + auto parsed = boost::json::parse(*body, ec); + if (ec) { + LD_LOG(logger_, LogLevel::kError) << kErrorParsingBody; + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody), + false}}; + } + + auto const* obj = parsed.if_object(); + if (!obj) { + LD_LOG(logger_, LogLevel::kError) << kErrorParsingBody; + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody), + false}}; + } + + auto const* events_val = obj->if_contains("events"); + if (!events_val) { + LD_LOG(logger_, LogLevel::kError) << kErrorMissingEvents; + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), + false}}; + } + + auto const* events_arr = events_val->if_array(); + if (!events_arr) { + LD_LOG(logger_, LogLevel::kError) << kErrorMissingEvents; + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), + false}}; + } + + for (auto const& event_val : *events_arr) { + auto const* event_obj = event_val.if_object(); + if (!event_obj) { + continue; + } + + auto const* event_type_val = event_obj->if_contains("event"); + auto const* event_data_val = event_obj->if_contains("data"); + if (!event_type_val || !event_data_val) { + continue; + } + + auto const* event_type_str = event_type_val->if_string(); + if (!event_type_str) { + continue; + } + + auto result = protocol_handler_.HandleEvent( + std::string_view{event_type_str->data(), + event_type_str->size()}, + *event_data_val); + + if (auto* changeset = + std::get_if(&result)) { + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::ChangeSet{ + std::move(*changeset), false}}; + } + if (auto* goodbye = std::get_if(&result)) { + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Goodbye{goodbye->reason, + false}}; + } + if (auto* error = std::get_if(&result)) { + std::string msg = "Server error: " + error->reason; + LD_LOG(logger_, LogLevel::kInfo) + << kIdentity << ": " << msg; + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kUnknown, 0, std::move(msg)), + false}}; + } + } + + LD_LOG(logger_, LogLevel::kError) << kErrorIncompletePayload; + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorIncompletePayload), + false}}; + } + + if (network::IsRecoverableStatus(res.Status())) { + std::string msg = network::ErrorForStatusCode( + res.Status(), "FDv2 polling request", "will retry"); + LD_LOG(logger_, LogLevel::kWarn) << kIdentity << ": " << msg; + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kErrorResponse, res.Status(), + std::move(msg)), + false}}; + } + + std::string msg = network::ErrorForStatusCode( + res.Status(), "FDv2 polling request", std::nullopt); + LD_LOG(logger_, LogLevel::kError) << kIdentity << ": " << msg; + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::TerminalError{ + MakeError(ErrorKind::kErrorResponse, res.Status(), std::move(msg)), + false}}; +} + +} // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp new file mode 100644 index 000000000..affe348e7 --- /dev/null +++ b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include "../../data_interfaces/source/ifdv2_initializer.hpp" + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace launchdarkly::server_side::data_systems { + +/** + * FDv2 polling initializer. Makes a single HTTP GET to the FDv2 polling + * endpoint, parses the response via the FDv2 protocol state machine, and + * returns the result. Implements IFDv2Initializer (blocking, one-shot). + */ +class FDv2PollingInitializer final + : public data_interfaces::IFDv2Initializer { + public: + FDv2PollingInitializer( + boost::asio::any_io_executor const& executor, + Logger const& logger, + config::built::ServiceEndpoints const& endpoints, + config::built::HttpProperties const& http_properties, + data_model::Selector selector, + std::optional filter_key); + + data_interfaces::FDv2SourceResult Run() override; + + void Close() override; + + [[nodiscard]] std::string const& Identity() const override; + + private: + data_interfaces::FDv2SourceResult HandlePollResult( + network::HttpResult const& res); + + Logger const& logger_; + network::AsioRequester requester_; + network::HttpRequest request_; + FDv2ProtocolHandler protocol_handler_; + + std::mutex mutex_; + std::condition_variable cv_; + bool closed_ = false; // guarded by mutex_ +}; + +} // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp new file mode 100644 index 000000000..39f0d40b3 --- /dev/null +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp @@ -0,0 +1,295 @@ +#include "polling_synchronizer.hpp" + +#include +#include +#include + +#include +#include + +#include + +namespace launchdarkly::server_side::data_systems { + +static char const* const kIdentity = "FDv2 polling synchronizer"; +static char const* const kFDv2PollPath = "/sdk/poll"; + +static char const* const kErrorParsingBody = + "Could not parse FDv2 polling response"; +static char const* const kErrorMissingEvents = + "FDv2 polling response missing 'events' array"; +static char const* const kErrorIncompletePayload = + "FDv2 polling response did not contain a complete payload"; + +// Minimum polling interval to prevent accidentally hammering the service. +static constexpr std::chrono::seconds kMinPollInterval{30}; + +using ErrorInfo = data_interfaces::FDv2SourceResult::ErrorInfo; +using ErrorKind = ErrorInfo::ErrorKind; + +static ErrorInfo MakeError(ErrorKind kind, + ErrorInfo::StatusCodeType status, + std::string message) { + return ErrorInfo{kind, status, std::move(message), + std::chrono::system_clock::now()}; +} + +FDv2PollingSynchronizer::FDv2PollingSynchronizer( + boost::asio::any_io_executor const& executor, + Logger const& logger, + config::built::ServiceEndpoints const& endpoints, + config::built::HttpProperties const& http_properties, + std::optional filter_key, + std::chrono::seconds poll_interval) + : logger_(logger), + requester_(executor, http_properties.Tls()), + endpoints_(endpoints), + http_properties_(http_properties), + filter_key_(std::move(filter_key)), + poll_interval_(std::max(poll_interval, kMinPollInterval)), + timer_(executor) { + if (poll_interval < kMinPollInterval) { + LD_LOG(logger_, LogLevel::kWarn) + << kIdentity << ": polling interval too frequent, defaulting to " + << kMinPollInterval.count() << " seconds"; + } +} + +network::HttpRequest FDv2PollingSynchronizer::MakeRequest( + data_model::Selector const& selector) const { + auto url = std::make_optional(endpoints_.PollingBaseUrl()); + url = network::AppendUrl(url, kFDv2PollPath); + + bool has_query = false; + if (selector.value) { + url->append("?basis=" + selector.value->state); + has_query = true; + } + + if (filter_key_) { + url->append(has_query ? "&filter=" : "?filter="); + url->append(*filter_key_); + } + + config::builders::HttpPropertiesBuilder const builder(http_properties_); + return {url.value_or(""), network::HttpMethod::kGet, builder.Build(), + network::HttpRequest::BodyType{}}; +} + +void FDv2PollingSynchronizer::DoPoll(data_model::Selector selector) { + last_poll_start_ = std::chrono::steady_clock::now(); + protocol_handler_.Reset(); + + auto request = MakeRequest(selector); + requester_.Request(request, [this](network::HttpResult res) { + std::lock_guard guard(mutex_); + result_ = std::move(res); + cv_.notify_one(); + }); +} + +data_interfaces::FDv2SourceResult FDv2PollingSynchronizer::Next( + std::chrono::milliseconds timeout, + data_model::Selector selector) { + std::unique_lock lock(mutex_); + + if (closed_) { + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Shutdown{}}; + } + + result_.reset(); + + if (!started_) { + started_ = true; + // First call: poll immediately (post to avoid holding the lock). + boost::asio::post(timer_.get_executor(), + [this, selector] { DoPoll(selector); }); + } else { + // Subsequent calls: schedule next poll after the remaining interval. + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - last_poll_start_); + auto delay = std::chrono::seconds( + std::max(poll_interval_ - elapsed, std::chrono::seconds(0))); + + timer_.cancel(); + timer_.expires_after(delay); + timer_.async_wait( + [this, selector](boost::system::error_code const& ec) { + if (ec == boost::asio::error::operation_aborted) { + return; + } + DoPoll(selector); + }); + } + + auto deadline = std::chrono::steady_clock::now() + timeout; + bool timed_out = !cv_.wait_until( + lock, deadline, [this] { return result_.has_value() || closed_; }); + + if (closed_) { + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Shutdown{}}; + } + + if (timed_out) { + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kUnknown, 0, "polling timeout"), false}}; + } + + return HandlePollResult(*result_); +} + +void FDv2PollingSynchronizer::Close() { + timer_.cancel(); + std::lock_guard lock(mutex_); + closed_ = true; + cv_.notify_one(); +} + +std::string const& FDv2PollingSynchronizer::Identity() const { + static std::string const identity = kIdentity; + return identity; +} + +data_interfaces::FDv2SourceResult FDv2PollingSynchronizer::HandlePollResult( + network::HttpResult const& res) { + if (res.IsError()) { + auto const& msg = res.ErrorMessage(); + std::string error_msg = msg.has_value() ? *msg : "unknown error"; + LD_LOG(logger_, LogLevel::kWarn) + << kIdentity << ": " << error_msg; + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kNetworkError, 0, std::move(error_msg)), + false}}; + } + + if (res.Status() == 304) { + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::ChangeSet{ + data_model::FDv2ChangeSet{ + data_model::FDv2ChangeSet::Type::kNone, {}, {}}, + false}}; + } + + if (res.Status() == 200) { + auto const& body = res.Body(); + if (!body) { + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, + "polling response contained no body"), + false}}; + } + + boost::system::error_code ec; + auto parsed = boost::json::parse(*body, ec); + if (ec) { + LD_LOG(logger_, LogLevel::kError) << kErrorParsingBody; + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody), + false}}; + } + + auto const* obj = parsed.if_object(); + if (!obj) { + LD_LOG(logger_, LogLevel::kError) << kErrorParsingBody; + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody), + false}}; + } + + auto const* events_val = obj->if_contains("events"); + if (!events_val) { + LD_LOG(logger_, LogLevel::kError) << kErrorMissingEvents; + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), + false}}; + } + + auto const* events_arr = events_val->if_array(); + if (!events_arr) { + LD_LOG(logger_, LogLevel::kError) << kErrorMissingEvents; + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), + false}}; + } + + for (auto const& event_val : *events_arr) { + auto const* event_obj = event_val.if_object(); + if (!event_obj) { + continue; + } + + auto const* event_type_val = event_obj->if_contains("event"); + auto const* event_data_val = event_obj->if_contains("data"); + if (!event_type_val || !event_data_val) { + continue; + } + + auto const* event_type_str = event_type_val->if_string(); + if (!event_type_str) { + continue; + } + + auto result = protocol_handler_.HandleEvent( + std::string_view{event_type_str->data(), + event_type_str->size()}, + *event_data_val); + + if (auto* changeset = + std::get_if(&result)) { + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::ChangeSet{ + std::move(*changeset), false}}; + } + if (auto* goodbye = std::get_if(&result)) { + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Goodbye{goodbye->reason, + false}}; + } + if (auto* error = std::get_if(&result)) { + std::string msg = "Server error: " + error->reason; + LD_LOG(logger_, LogLevel::kInfo) + << kIdentity << ": " << msg; + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kUnknown, 0, std::move(msg)), + false}}; + } + } + + LD_LOG(logger_, LogLevel::kError) << kErrorIncompletePayload; + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorIncompletePayload), + false}}; + } + + if (network::IsRecoverableStatus(res.Status())) { + std::string msg = network::ErrorForStatusCode( + res.Status(), "FDv2 polling request", "will retry"); + LD_LOG(logger_, LogLevel::kWarn) << kIdentity << ": " << msg; + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kErrorResponse, res.Status(), + std::move(msg)), + false}}; + } + + std::string msg = network::ErrorForStatusCode( + res.Status(), "FDv2 polling request", std::nullopt); + LD_LOG(logger_, LogLevel::kError) << kIdentity << ": " << msg; + return data_interfaces::FDv2SourceResult{ + data_interfaces::FDv2SourceResult::TerminalError{ + MakeError(ErrorKind::kErrorResponse, res.Status(), std::move(msg)), + false}}; +} + +} // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp new file mode 100644 index 000000000..38cf9a61f --- /dev/null +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp @@ -0,0 +1,71 @@ +#pragma once + +#include "../../data_interfaces/source/ifdv2_synchronizer.hpp" + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace launchdarkly::server_side::data_systems { + +/** + * FDv2 polling synchronizer. Repeatedly polls the FDv2 polling endpoint at + * a configurable interval. Implements IFDv2Synchronizer (blocking). + * + * The caller passes the current selector into each Next() call, allowing the + * orchestrator to reflect applied changesets without any shared state. + */ +class FDv2PollingSynchronizer final + : public data_interfaces::IFDv2Synchronizer { + public: + FDv2PollingSynchronizer( + boost::asio::any_io_executor const& executor, + Logger const& logger, + config::built::ServiceEndpoints const& endpoints, + config::built::HttpProperties const& http_properties, + std::optional filter_key, + std::chrono::seconds poll_interval); + + data_interfaces::FDv2SourceResult Next( + std::chrono::milliseconds timeout, + data_model::Selector selector) override; + + void Close() override; + + [[nodiscard]] std::string const& Identity() const override; + + private: + void DoPoll(data_model::Selector selector); + network::HttpRequest MakeRequest(data_model::Selector const& selector) const; + data_interfaces::FDv2SourceResult HandlePollResult( + network::HttpResult const& res); + + Logger const& logger_; + network::AsioRequester requester_; + config::built::ServiceEndpoints const& endpoints_; + config::built::HttpProperties const& http_properties_; + std::optional filter_key_; + std::chrono::seconds poll_interval_; + boost::asio::steady_timer timer_; + FDv2ProtocolHandler protocol_handler_; + + bool started_ = false; + std::chrono::time_point last_poll_start_; + + std::mutex mutex_; + std::condition_variable cv_; + std::optional result_; // guarded by mutex_ + bool closed_ = false; // guarded by mutex_ +}; + +} // namespace launchdarkly::server_side::data_systems From 29a94c8cc23d0ffc8b259873575190dba5f3f616 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Thu, 2 Apr 2026 11:45:21 -0700 Subject: [PATCH 11/35] update for upstream change --- libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp index 39f0d40b3..381b85efc 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp @@ -134,8 +134,7 @@ data_interfaces::FDv2SourceResult FDv2PollingSynchronizer::Next( if (timed_out) { return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kUnknown, 0, "polling timeout"), false}}; + data_interfaces::FDv2SourceResult::Timeout{}}; } return HandlePollResult(*result_); From 0996ccabb8d261a6bf396b5773ecdcb60faa2cbb Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Fri, 3 Apr 2026 15:52:59 -0700 Subject: [PATCH 12/35] refactor: fix an error type that could be better --- libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp index 07593a53f..566dec99e 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp @@ -73,7 +73,7 @@ data_interfaces::FDv2SourceResult FDv2PollingInitializer::Run() { << kIdentity << ": invalid polling endpoint URL"; return data_interfaces::FDv2SourceResult{ data_interfaces::FDv2SourceResult::TerminalError{ - MakeError(ErrorKind::kNetworkError, 0, + MakeError(ErrorKind::kUnknown, 0, "invalid polling endpoint URL"), false}}; } From 92489a654e14ad9b6218a19a7adb5230dbe05b9b Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Tue, 7 Apr 2026 09:34:29 -0700 Subject: [PATCH 13/35] refactor polling synchronizer to make it more threadsafe --- .../fdv2/polling_synchronizer.cpp | 275 +++++++++++------- .../fdv2/polling_synchronizer.hpp | 64 +++- 2 files changed, 218 insertions(+), 121 deletions(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp index 381b85efc..4e1a3a331 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp @@ -4,10 +4,15 @@ #include #include +#include +#include +#include #include #include #include +#include +#include namespace launchdarkly::server_side::data_systems { @@ -24,7 +29,8 @@ static char const* const kErrorIncompletePayload = // Minimum polling interval to prevent accidentally hammering the service. static constexpr std::chrono::seconds kMinPollInterval{30}; -using ErrorInfo = data_interfaces::FDv2SourceResult::ErrorInfo; +using data_interfaces::FDv2SourceResult; +using ErrorInfo = FDv2SourceResult::ErrorInfo; using ErrorKind = ErrorInfo::ErrorKind; static ErrorInfo MakeError(ErrorKind kind, @@ -76,75 +82,145 @@ network::HttpRequest FDv2PollingSynchronizer::MakeRequest( network::HttpRequest::BodyType{}}; } -void FDv2PollingSynchronizer::DoPoll(data_model::Selector selector) { - last_poll_start_ = std::chrono::steady_clock::now(); - protocol_handler_.Reset(); +FDv2SourceResult FDv2PollingSynchronizer::Next( + std::chrono::milliseconds timeout, + data_model::Selector selector) { + if (closed_) { + return FDv2SourceResult{FDv2SourceResult::Shutdown{}}; + } - auto request = MakeRequest(selector); - requester_.Request(request, [this](network::HttpResult res) { - std::lock_guard guard(mutex_); - result_ = std::move(res); - cv_.notify_one(); - }); + auto promise = std::make_shared>(); + auto future = promise->get_future(); + + // Post the actual work to the ASIO thread so that timer_ and + // cancel_signal_ are only ever accessed from the executor. Calling their + // methods directly here (on the orchestrator thread) would be a data race, + // since ASIO I/O objects and cancellation_signal are not thread-safe. + // future.get() below blocks the orchestrator thread until DoNext/DoPoll + // sets the promise from the ASIO thread. + boost::asio::post( + timer_.get_executor(), + [this, timeout, selector = std::move(selector), promise]() mutable { + DoNext(timeout, std::move(selector), std::move(promise)); + }); + + return future.get(); } -data_interfaces::FDv2SourceResult FDv2PollingSynchronizer::Next( +void FDv2PollingSynchronizer::DoNext( std::chrono::milliseconds timeout, - data_model::Selector selector) { - std::unique_lock lock(mutex_); - + data_model::Selector selector, + std::shared_ptr> promise) { if (closed_) { - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Shutdown{}}; + promise->set_value(FDv2SourceResult{FDv2SourceResult::Shutdown{}}); + return; } - result_.reset(); + auto deadline = std::chrono::steady_clock::now() + timeout; - if (!started_) { - started_ = true; - // First call: poll immediately (post to avoid holding the lock). - boost::asio::post(timer_.get_executor(), - [this, selector] { DoPoll(selector); }); - } else { - // Subsequent calls: schedule next poll after the remaining interval. - auto elapsed = std::chrono::duration_cast( + if (started_) { + auto elapsed = std::chrono::duration_cast( std::chrono::steady_clock::now() - last_poll_start_); - auto delay = std::chrono::seconds( - std::max(poll_interval_ - elapsed, std::chrono::seconds(0))); - - timer_.cancel(); - timer_.expires_after(delay); - timer_.async_wait( - [this, selector](boost::system::error_code const& ec) { - if (ec == boost::asio::error::operation_aborted) { - return; - } - DoPoll(selector); - }); + auto delay = std::chrono::duration_cast( + poll_interval_) - + elapsed; + + if (delay.count() > 0) { + auto remaining = + std::chrono::duration_cast( + deadline - std::chrono::steady_clock::now()); + + // timeout_timer must outlive this function: the io_context's + // internal timer heap holds a pointer to the timer's + // implementation until the async_wait completes, and the + // destructor cancels any pending async_wait (posting + // operation_aborted), which would make the parallel_group + // immediately complete as if the timeout had fired. Capturing + // the shared_ptr in the callback lambda keeps the timer alive + // until the group completes. + auto timeout_timer = std::make_shared( + timer_.get_executor()); + timer_.expires_after(delay); + timeout_timer->expires_after(remaining); + + boost::asio::experimental::make_parallel_group( + timer_.async_wait(boost::asio::deferred), + timeout_timer->async_wait(boost::asio::deferred)) + .async_wait(boost::asio::experimental::wait_for_one(), + boost::asio::bind_cancellation_slot( + cancel_signal_.slot(), + [this, deadline, selector = std::move(selector), + promise, timeout_timer]( + std::array order, + boost::system::error_code, + boost::system::error_code) mutable { + if (closed_) { + promise->set_value(FDv2SourceResult{ + FDv2SourceResult::Shutdown{}}); + return; + } + if (order[0] == 1) { + promise->set_value(FDv2SourceResult{ + FDv2SourceResult::Timeout{}}); + return; + } + DoPoll(deadline, std::move(selector), + std::move(promise)); + })); + return; + } } - auto deadline = std::chrono::steady_clock::now() + timeout; - bool timed_out = !cv_.wait_until( - lock, deadline, [this] { return result_.has_value() || closed_; }); + DoPoll(deadline, std::move(selector), std::move(promise)); +} +void FDv2PollingSynchronizer::DoPoll( + std::chrono::time_point deadline, + data_model::Selector selector, + std::shared_ptr> promise) { if (closed_) { - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Shutdown{}}; + promise->set_value(FDv2SourceResult{FDv2SourceResult::Shutdown{}}); + return; } - if (timed_out) { - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Timeout{}}; - } + started_ = true; + last_poll_start_ = std::chrono::steady_clock::now(); + protocol_handler_.Reset(); - return HandlePollResult(*result_); + auto remaining = std::chrono::duration_cast( + deadline - std::chrono::steady_clock::now()); + timer_.expires_after(remaining); + + boost::asio::experimental::make_parallel_group( + requester_.Request(MakeRequest(selector), boost::asio::deferred), + timer_.async_wait(boost::asio::deferred)) + .async_wait( + boost::asio::experimental::wait_for_one(), + boost::asio::bind_cancellation_slot( + cancel_signal_.slot(), + [this, promise](std::array order, + network::HttpResult poll_result, + boost::system::error_code) mutable { + if (order[0] == 0) { + promise->set_value(HandlePollResult(poll_result)); + } else if (closed_) { + promise->set_value( + FDv2SourceResult{FDv2SourceResult::Shutdown{}}); + } else { + promise->set_value( + FDv2SourceResult{FDv2SourceResult::Timeout{}}); + } + })); } void FDv2PollingSynchronizer::Close() { - timer_.cancel(); - std::lock_guard lock(mutex_); closed_ = true; - cv_.notify_one(); + // cancel_signal_ is not thread-safe, so emit() must run on the ASIO + // thread. post() schedules it there rather than calling it directly, + // which would race with DoNext/DoPoll accessing the signal concurrently. + boost::asio::post(timer_.get_executor(), [this] { + cancel_signal_.emit(boost::asio::cancellation_type::all); + }); } std::string const& FDv2PollingSynchronizer::Identity() const { @@ -152,72 +228,64 @@ std::string const& FDv2PollingSynchronizer::Identity() const { return identity; } -data_interfaces::FDv2SourceResult FDv2PollingSynchronizer::HandlePollResult( +FDv2SourceResult FDv2PollingSynchronizer::HandlePollResult( network::HttpResult const& res) { if (res.IsError()) { auto const& msg = res.ErrorMessage(); std::string error_msg = msg.has_value() ? *msg : "unknown error"; - LD_LOG(logger_, LogLevel::kWarn) - << kIdentity << ": " << error_msg; - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kNetworkError, 0, std::move(error_msg)), - false}}; + LD_LOG(logger_, LogLevel::kWarn) << kIdentity << ": " << error_msg; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kNetworkError, 0, std::move(error_msg)), + false}}; } if (res.Status() == 304) { - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::ChangeSet{ - data_model::FDv2ChangeSet{ - data_model::FDv2ChangeSet::Type::kNone, {}, {}}, - false}}; + return FDv2SourceResult{FDv2SourceResult::ChangeSet{ + data_model::FDv2ChangeSet{ + data_model::FDv2ChangeSet::Type::kNone, {}, {}}, + false}}; } if (res.Status() == 200) { auto const& body = res.Body(); if (!body) { - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, - "polling response contained no body"), - false}}; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, + "polling response contained no body"), + false}}; } boost::system::error_code ec; auto parsed = boost::json::parse(*body, ec); if (ec) { LD_LOG(logger_, LogLevel::kError) << kErrorParsingBody; - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody), - false}}; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody), + false}}; } auto const* obj = parsed.if_object(); if (!obj) { LD_LOG(logger_, LogLevel::kError) << kErrorParsingBody; - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody), - false}}; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody), + false}}; } auto const* events_val = obj->if_contains("events"); if (!events_val) { LD_LOG(logger_, LogLevel::kError) << kErrorMissingEvents; - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), - false}}; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), + false}}; } auto const* events_arr = events_val->if_array(); if (!events_arr) { LD_LOG(logger_, LogLevel::kError) << kErrorMissingEvents; - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), - false}}; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), + false}}; } for (auto const& event_val : *events_arr) { @@ -244,51 +312,42 @@ data_interfaces::FDv2SourceResult FDv2PollingSynchronizer::HandlePollResult( if (auto* changeset = std::get_if(&result)) { - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::ChangeSet{ - std::move(*changeset), false}}; + return FDv2SourceResult{ + FDv2SourceResult::ChangeSet{std::move(*changeset), false}}; } if (auto* goodbye = std::get_if(&result)) { - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Goodbye{goodbye->reason, - false}}; + return FDv2SourceResult{ + FDv2SourceResult::Goodbye{goodbye->reason, false}}; } if (auto* error = std::get_if(&result)) { std::string msg = "Server error: " + error->reason; - LD_LOG(logger_, LogLevel::kInfo) - << kIdentity << ": " << msg; - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kUnknown, 0, std::move(msg)), - false}}; + LD_LOG(logger_, LogLevel::kInfo) << kIdentity << ": " << msg; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kUnknown, 0, std::move(msg)), false}}; } } LD_LOG(logger_, LogLevel::kError) << kErrorIncompletePayload; - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorIncompletePayload), - false}}; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorIncompletePayload), + false}}; } if (network::IsRecoverableStatus(res.Status())) { std::string msg = network::ErrorForStatusCode( res.Status(), "FDv2 polling request", "will retry"); LD_LOG(logger_, LogLevel::kWarn) << kIdentity << ": " << msg; - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kErrorResponse, res.Status(), - std::move(msg)), - false}}; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kErrorResponse, res.Status(), std::move(msg)), + false}}; } std::string msg = network::ErrorForStatusCode( res.Status(), "FDv2 polling request", std::nullopt); LD_LOG(logger_, LogLevel::kError) << kIdentity << ": " << msg; - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::TerminalError{ - MakeError(ErrorKind::kErrorResponse, res.Status(), std::move(msg)), - false}}; + return FDv2SourceResult{FDv2SourceResult::TerminalError{ + MakeError(ErrorKind::kErrorResponse, res.Status(), std::move(msg)), + false}}; } } // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp index 38cf9a61f..a530589c6 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp @@ -8,11 +8,13 @@ #include #include +#include #include +#include #include -#include -#include +#include +#include #include #include @@ -24,6 +26,14 @@ namespace launchdarkly::server_side::data_systems { * * The caller passes the current selector into each Next() call, allowing the * orchestrator to reflect applied changesets without any shared state. + * + * Threading model: + * Next() may be called from any single thread (the orchestrator thread). + * Close() may be called from any thread, concurrently with Next(). + * Next() posts work to the ASIO executor and blocks on a future; the actual + * I/O and timer operations run on the ASIO thread. Destroying this object + * is not safe until the ASIO thread has been joined, because in-flight + * callbacks posted to the executor may still reference member variables. */ class FDv2PollingSynchronizer final : public data_interfaces::IFDv2Synchronizer { @@ -45,27 +55,55 @@ class FDv2PollingSynchronizer final [[nodiscard]] std::string const& Identity() const override; private: - void DoPoll(data_model::Selector selector); - network::HttpRequest MakeRequest(data_model::Selector const& selector) const; + // Called on the ASIO thread. Waits out any remaining inter-poll delay, + // then calls DoPoll. + void DoNext(std::chrono::milliseconds timeout, + data_model::Selector selector, + std::shared_ptr> + promise); + + // Called on the ASIO thread. Fires the HTTP request and waits for the + // response or timeout, then sets the promise. + void DoPoll(std::chrono::time_point deadline, + data_model::Selector selector, + std::shared_ptr> + promise); + + network::HttpRequest MakeRequest( + data_model::Selector const& selector) const; data_interfaces::FDv2SourceResult HandlePollResult( network::HttpResult const& res); + // Immutable after construction; safe to read from any thread. Logger const& logger_; - network::AsioRequester requester_; config::built::ServiceEndpoints const& endpoints_; config::built::HttpProperties const& http_properties_; - std::optional filter_key_; - std::chrono::seconds poll_interval_; - boost::asio::steady_timer timer_; - FDv2ProtocolHandler protocol_handler_; + std::optional const filter_key_; + std::chrono::seconds const poll_interval_; + // Mutable state accessed only from the ASIO thread (via DoNext/DoPoll). + network::AsioRequester requester_; + FDv2ProtocolHandler protocol_handler_; bool started_ = false; std::chrono::time_point last_poll_start_; - std::mutex mutex_; - std::condition_variable cv_; - std::optional result_; // guarded by mutex_ - bool closed_ = false; // guarded by mutex_ + // Thread-safety-sensitive members. See threading model note in the class + // doc above. + + // Accessed only from the ASIO thread. ASIO I/O objects are not thread-safe; + // all operations on timer_ must run on the executor. + boost::asio::steady_timer timer_; + + // Written by Close() from any thread; read by DoNext/DoPoll on the ASIO + // thread. Must be atomic to avoid a data race. + std::atomic closed_{false}; + + // Accessed only from the ASIO thread. boost::asio::cancellation_signal is + // not thread-safe: slot() (which registers a handler) and emit() (which + // fires it) must not be called concurrently from different threads. Both + // happen on the ASIO thread here: DoNext/DoPoll call slot() when initiating + // a parallel_group, and Close() posts emit() to the executor. + boost::asio::cancellation_signal cancel_signal_; }; } // namespace launchdarkly::server_side::data_systems From 071a4eb2eca83f4f8d7aafc4d6c9ea8da25de463 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Tue, 7 Apr 2026 09:35:31 -0700 Subject: [PATCH 14/35] update asio_requester to use new style --- .../launchdarkly/network/asio_requester.hpp | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/libs/internal/include/launchdarkly/network/asio_requester.hpp b/libs/internal/include/launchdarkly/network/asio_requester.hpp index a4078953c..1adb64e49 100644 --- a/libs/internal/include/launchdarkly/network/asio_requester.hpp +++ b/libs/internal/include/launchdarkly/network/asio_requester.hpp @@ -283,22 +283,15 @@ class AsioRequester { template auto Request(HttpRequest request, CompletionToken&& token) { - // TODO: Clang-tidy wants to pass the request by reference, but I am not - // confident that lifetime would make sense. - - namespace asio = boost::asio; - namespace system = boost::system; - - using Sig = void(HttpResult result); - using Result = asio::async_result, Sig>; - using Handler = typename Result::completion_handler_type; - - Handler handler(std::forward(token)); - Result result(handler); - - InnerRequest(net::make_strand(ctx_), request, std::move(handler), 0); - - return result.get(); + return boost::asio::async_initiate( + [this](auto handler, HttpRequest req) { + InnerRequest(net::make_strand(ctx_), std::move(req), + [h = std::move(handler)](HttpResult result) mutable { + std::move(h)(std::move(result)); + }, + 0); + }, + token, std::move(request)); } private: From f1b3f06824345c24dde75c11bc417e537c308b7f Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Tue, 7 Apr 2026 09:43:38 -0700 Subject: [PATCH 15/35] add comments --- .../data_systems/fdv2/polling_initializer.cpp | 140 ++++++++---------- .../data_systems/fdv2/polling_initializer.hpp | 33 +++-- 2 files changed, 85 insertions(+), 88 deletions(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp index 566dec99e..f4cfd2d02 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp @@ -20,7 +20,8 @@ static char const* const kErrorMissingEvents = static char const* const kErrorIncompletePayload = "FDv2 polling response did not contain a complete payload"; -using ErrorInfo = data_interfaces::FDv2SourceResult::ErrorInfo; +using data_interfaces::FDv2SourceResult; +using ErrorInfo = FDv2SourceResult::ErrorInfo; using ErrorKind = ErrorInfo::ErrorKind; static ErrorInfo MakeError(ErrorKind kind, @@ -63,36 +64,36 @@ FDv2PollingInitializer::FDv2PollingInitializer( data_model::Selector selector, std::optional filter_key) : logger_(logger), - requester_(executor, http_properties.Tls()), - request_(MakeRequest(logger, endpoints, http_properties, selector, - filter_key)) {} - -data_interfaces::FDv2SourceResult FDv2PollingInitializer::Run() { + request_(MakeRequest(logger, + endpoints, + http_properties, + selector, + filter_key)), + requester_(executor, http_properties.Tls()) {} + +FDv2SourceResult FDv2PollingInitializer::Run() { if (!request_.Valid()) { LD_LOG(logger_, LogLevel::kError) << kIdentity << ": invalid polling endpoint URL"; - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::TerminalError{ - MakeError(ErrorKind::kUnknown, 0, - "invalid polling endpoint URL"), - false}}; + return FDv2SourceResult{FDv2SourceResult::TerminalError{ + MakeError(ErrorKind::kUnknown, 0, "invalid polling endpoint URL"), + false}}; } - auto shared_result = - std::make_shared>(); + auto shared_result = std::make_shared>(); - requester_.Request(request_, [this, shared_result](network::HttpResult res) { - std::lock_guard guard(mutex_); - *shared_result = std::move(res); - cv_.notify_one(); - }); + requester_.Request(request_, + [this, shared_result](network::HttpResult res) { + std::lock_guard guard(mutex_); + *shared_result = std::move(res); + cv_.notify_one(); + }); std::unique_lock lock(mutex_); cv_.wait(lock, [&] { return shared_result->has_value() || closed_; }); if (closed_) { - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Shutdown{}}; + return FDv2SourceResult{FDv2SourceResult::Shutdown{}}; } auto http_result = std::move(**shared_result); @@ -112,72 +113,64 @@ std::string const& FDv2PollingInitializer::Identity() const { return identity; } -data_interfaces::FDv2SourceResult FDv2PollingInitializer::HandlePollResult( +FDv2SourceResult FDv2PollingInitializer::HandlePollResult( network::HttpResult const& res) { if (res.IsError()) { auto const& msg = res.ErrorMessage(); std::string error_msg = msg.has_value() ? *msg : "unknown error"; - LD_LOG(logger_, LogLevel::kWarn) - << kIdentity << ": " << error_msg; - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kNetworkError, 0, std::move(error_msg)), - false}}; + LD_LOG(logger_, LogLevel::kWarn) << kIdentity << ": " << error_msg; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kNetworkError, 0, std::move(error_msg)), + false}}; } if (res.Status() == 304) { - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::ChangeSet{ - data_model::FDv2ChangeSet{ - data_model::FDv2ChangeSet::Type::kNone, {}, {}}, - false}}; + return FDv2SourceResult{FDv2SourceResult::ChangeSet{ + data_model::FDv2ChangeSet{ + data_model::FDv2ChangeSet::Type::kNone, {}, {}}, + false}}; } if (res.Status() == 200) { auto const& body = res.Body(); if (!body) { - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, - "polling response contained no body"), - false}}; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, + "polling response contained no body"), + false}}; } boost::system::error_code ec; auto parsed = boost::json::parse(*body, ec); if (ec) { LD_LOG(logger_, LogLevel::kError) << kErrorParsingBody; - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody), - false}}; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody), + false}}; } auto const* obj = parsed.if_object(); if (!obj) { LD_LOG(logger_, LogLevel::kError) << kErrorParsingBody; - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody), - false}}; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody), + false}}; } auto const* events_val = obj->if_contains("events"); if (!events_val) { LD_LOG(logger_, LogLevel::kError) << kErrorMissingEvents; - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), - false}}; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), + false}}; } auto const* events_arr = events_val->if_array(); if (!events_arr) { LD_LOG(logger_, LogLevel::kError) << kErrorMissingEvents; - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), - false}}; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), + false}}; } for (auto const& event_val : *events_arr) { @@ -204,51 +197,42 @@ data_interfaces::FDv2SourceResult FDv2PollingInitializer::HandlePollResult( if (auto* changeset = std::get_if(&result)) { - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::ChangeSet{ - std::move(*changeset), false}}; + return FDv2SourceResult{ + FDv2SourceResult::ChangeSet{std::move(*changeset), false}}; } if (auto* goodbye = std::get_if(&result)) { - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Goodbye{goodbye->reason, - false}}; + return FDv2SourceResult{ + FDv2SourceResult::Goodbye{goodbye->reason, false}}; } if (auto* error = std::get_if(&result)) { std::string msg = "Server error: " + error->reason; - LD_LOG(logger_, LogLevel::kInfo) - << kIdentity << ": " << msg; - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kUnknown, 0, std::move(msg)), - false}}; + LD_LOG(logger_, LogLevel::kInfo) << kIdentity << ": " << msg; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kUnknown, 0, std::move(msg)), false}}; } } LD_LOG(logger_, LogLevel::kError) << kErrorIncompletePayload; - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorIncompletePayload), - false}}; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorIncompletePayload), + false}}; } if (network::IsRecoverableStatus(res.Status())) { std::string msg = network::ErrorForStatusCode( res.Status(), "FDv2 polling request", "will retry"); LD_LOG(logger_, LogLevel::kWarn) << kIdentity << ": " << msg; - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kErrorResponse, res.Status(), - std::move(msg)), - false}}; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kErrorResponse, res.Status(), std::move(msg)), + false}}; } std::string msg = network::ErrorForStatusCode( res.Status(), "FDv2 polling request", std::nullopt); LD_LOG(logger_, LogLevel::kError) << kIdentity << ": " << msg; - return data_interfaces::FDv2SourceResult{ - data_interfaces::FDv2SourceResult::TerminalError{ - MakeError(ErrorKind::kErrorResponse, res.Status(), std::move(msg)), - false}}; + return FDv2SourceResult{FDv2SourceResult::TerminalError{ + MakeError(ErrorKind::kErrorResponse, res.Status(), std::move(msg)), + false}}; } } // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp index affe348e7..300ac1c3d 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp @@ -21,17 +21,24 @@ namespace launchdarkly::server_side::data_systems { * FDv2 polling initializer. Makes a single HTTP GET to the FDv2 polling * endpoint, parses the response via the FDv2 protocol state machine, and * returns the result. Implements IFDv2Initializer (blocking, one-shot). + * + * Threading model: + * Run() is called once from the orchestrator thread. It posts the HTTP + * request to the ASIO executor and blocks on a condition variable until + * the response arrives or Close() is called. + * Close() may be called from any thread, concurrently with Run(). + * Destroying this object is not safe until the ASIO thread has been + * joined, because the HTTP response callback posted to the executor + * captures a pointer to this object's mutex and condition variable. */ -class FDv2PollingInitializer final - : public data_interfaces::IFDv2Initializer { +class FDv2PollingInitializer final : public data_interfaces::IFDv2Initializer { public: - FDv2PollingInitializer( - boost::asio::any_io_executor const& executor, - Logger const& logger, - config::built::ServiceEndpoints const& endpoints, - config::built::HttpProperties const& http_properties, - data_model::Selector selector, - std::optional filter_key); + FDv2PollingInitializer(boost::asio::any_io_executor const& executor, + Logger const& logger, + config::built::ServiceEndpoints const& endpoints, + config::built::HttpProperties const& http_properties, + data_model::Selector selector, + std::optional filter_key); data_interfaces::FDv2SourceResult Run() override; @@ -43,11 +50,17 @@ class FDv2PollingInitializer final data_interfaces::FDv2SourceResult HandlePollResult( network::HttpResult const& res); + // Immutable after construction; safe to read from any thread. Logger const& logger_; + network::HttpRequest const request_; + + // Mutable state accessed only from the ASIO thread (via the + // requester_ callback). network::AsioRequester requester_; - network::HttpRequest request_; FDv2ProtocolHandler protocol_handler_; + // Cross-thread synchronization. Run() waits on cv_ for either a + // response from the ASIO thread or a Close() from any thread. std::mutex mutex_; std::condition_variable cv_; bool closed_ = false; // guarded by mutex_ From 37fb4c0727ea93351b9297b435e31774f1a67b8e Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Tue, 7 Apr 2026 11:42:32 -0700 Subject: [PATCH 16/35] fix missing kInactive handling --- libs/internal/src/fdv2_protocol_handler.cpp | 6 ++++++ libs/internal/tests/fdv2_protocol_handler_test.cpp | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/libs/internal/src/fdv2_protocol_handler.cpp b/libs/internal/src/fdv2_protocol_handler.cpp index 0e6b7380c..3f7798062 100644 --- a/libs/internal/src/fdv2_protocol_handler.cpp +++ b/libs/internal/src/fdv2_protocol_handler.cpp @@ -159,6 +159,12 @@ FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleEvent( } if (event_type == kPayloadTransferred) { + if (state_ == State::kInactive) { + Reset(); + return FDv2Error{std::nullopt, + "payload-transferred received without an active " + "server-intent"}; + } auto result = boost::json::value_to< tl::expected, JsonError>>(data); if (!result) { diff --git a/libs/internal/tests/fdv2_protocol_handler_test.cpp b/libs/internal/tests/fdv2_protocol_handler_test.cpp index b6b12087e..fc10ef81d 100644 --- a/libs/internal/tests/fdv2_protocol_handler_test.cpp +++ b/libs/internal/tests/fdv2_protocol_handler_test.cpp @@ -275,3 +275,12 @@ TEST(FDv2ProtocolHandlerTest, ResetClearsState) { ASSERT_NE(cs, nullptr); EXPECT_TRUE(cs->changes.empty()); } + +TEST(FDv2ProtocolHandlerTest, PayloadTransferredWithoutServerIntentIsError) { + FDv2ProtocolHandler handler; + + auto result = handler.HandleEvent( + "payload-transferred", MakePayloadTransferred("s", 1)); + + EXPECT_NE(std::get_if(&result), nullptr); +} From 07810cd1f9b436f72e45f6a3f932b7b4ede4ad14 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Tue, 7 Apr 2026 13:03:22 -0700 Subject: [PATCH 17/35] refactor error types to preserve more info --- .../launchdarkly/fdv2_protocol_handler.hpp | 51 +++++++++++++++- libs/internal/src/fdv2_protocol_handler.cpp | 59 +++++++++++-------- .../tests/fdv2_protocol_handler_test.cpp | 46 +++++++++++++-- .../data_systems/fdv2/polling_initializer.cpp | 24 ++++++-- .../fdv2/polling_synchronizer.cpp | 24 ++++++-- 5 files changed, 165 insertions(+), 39 deletions(-) diff --git a/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp b/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp index 140d38dfc..c1a2bdc24 100644 --- a/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp +++ b/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp @@ -21,16 +21,63 @@ namespace launchdarkly { */ class FDv2ProtocolHandler { public: + /** + * Typed error returned by HandleEvent. Carries the original underlying + * error context rather than converting to a plain string. + */ + struct Error { + enum class Kind { + kJsonError, // Failed to deserialise an event's data field. + kProtocolError, // Out-of-order or unexpected event. + kServerError, // Server sent a valid 'error' event. + }; + + Kind kind; + std::string message; + + /** + * Set for kJsonError when the tl::expected parse returned an error. + * Nullopt when parse succeeded but the data value was null. + */ + std::optional json_error; + + /** + * Set for kServerError: the full wire error including id and reason. + */ + std::optional server_error; + + /** JSON deserialisation failed — carries the original JsonError. */ + static Error JsonParseError(JsonError err, std::string msg) { + return {Kind::kJsonError, std::move(msg), err, std::nullopt}; + } + /** Parse succeeded but data was null — no underlying JsonError. */ + static Error JsonParseError(std::string msg) { + return {Kind::kJsonError, std::move(msg), std::nullopt, + std::nullopt}; + } + /** Out-of-order or unexpected protocol event. */ + static Error ProtocolError(std::string msg) { + return {Kind::kProtocolError, std::move(msg), std::nullopt, + std::nullopt}; + } + /** Server sent a well-formed 'error' event. */ + static Error ServerError(FDv2Error err) { + return {Kind::kServerError, err.reason, std::nullopt, + std::move(err)}; + } + }; + /** * Result of handling a single FDv2 event: * - monostate: no output yet (accumulating, heartbeat, or unknown event) * - FDv2ChangeSet: complete changeset ready to apply - * - FDv2Error: server reported an error; discard partial data + * - Error: protocol error (JSON parse failure, protocol violation, or + * server-sent error event) * - Goodbye: server is closing; caller should rotate sources */ using Result = std::variant; /** diff --git a/libs/internal/src/fdv2_protocol_handler.cpp b/libs/internal/src/fdv2_protocol_handler.cpp index 3f7798062..89a7c54c2 100644 --- a/libs/internal/src/fdv2_protocol_handler.cpp +++ b/libs/internal/src/fdv2_protocol_handler.cpp @@ -18,10 +18,12 @@ static char const* const kPayloadTransferred = "payload-transferred"; static char const* const kError = "error"; static char const* const kGoodbye = "goodbye"; +using Error = FDv2ProtocolHandler::Error; + // Returns the parsed FDv2Change on success, nullopt for unknown kinds (which -// should be silently skipped for forward-compatibility), or an error string if +// should be silently skipped for forward-compatibility), or an Error if // a known kind fails to deserialize. -static tl::expected, std::string> +static tl::expected, Error> ParsePut(PutObject const& put) { if (put.kind == "flag") { auto result = boost::json::value_to< @@ -30,11 +32,13 @@ ParsePut(PutObject const& put) { // One bad flag aborts the entire transfer so the store is never // left in a partially-updated state. if (!result) { - return tl::make_unexpected("could not deserialize flag '" + - put.key + "'"); + return tl::make_unexpected(Error::JsonParseError( + result.error(), + "could not deserialize flag '" + put.key + "'")); } if (!result->has_value()) { - return tl::make_unexpected("flag '" + put.key + "' object was null"); + return tl::make_unexpected( + Error::JsonParseError("flag '" + put.key + "' object was null")); } return data_model::FDv2Change{ put.key, @@ -47,12 +51,13 @@ ParsePut(PutObject const& put) { // One bad segment aborts the entire transfer so the store is never // left in a partially-updated state. if (!result) { - return tl::make_unexpected("could not deserialize segment '" + - put.key + "'"); + return tl::make_unexpected(Error::JsonParseError( + result.error(), + "could not deserialize segment '" + put.key + "'")); } if (!result->has_value()) { - return tl::make_unexpected("segment '" + put.key + - "' object was null"); + return tl::make_unexpected(Error::JsonParseError( + "segment '" + put.key + "' object was null")); } return data_model::FDv2Change{ put.key, @@ -84,11 +89,12 @@ FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleEvent( tl::expected, JsonError>>(data); if (!result) { Reset(); - return FDv2Error{std::nullopt, "could not deserialize server-intent"}; + return Error::JsonParseError(result.error(), + "could not deserialize server-intent"); } if (!result->has_value()) { Reset(); - return FDv2Error{std::nullopt, "server-intent data was null"}; + return Error::JsonParseError("server-intent data was null"); } auto const& intent = **result; if (intent.payloads.empty()) { @@ -118,16 +124,17 @@ FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleEvent( tl::expected, JsonError>>(data); if (!result) { Reset(); - return FDv2Error{std::nullopt, "could not deserialize put-object"}; + return Error::JsonParseError(result.error(), + "could not deserialize put-object"); } if (!result->has_value()) { Reset(); - return FDv2Error{std::nullopt, "put-object data was null"}; + return Error::JsonParseError("put-object data was null"); } auto change = ParsePut(**result); if (!change) { Reset(); - return FDv2Error{std::nullopt, std::move(change.error())}; + return std::move(change.error()); } if (*change) { changes_.push_back(std::move(**change)); @@ -143,11 +150,12 @@ FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleEvent( tl::expected, JsonError>>(data); if (!result) { Reset(); - return FDv2Error{std::nullopt, "could not deserialize delete-object"}; + return Error::JsonParseError(result.error(), + "could not deserialize delete-object"); } if (!result->has_value()) { Reset(); - return FDv2Error{std::nullopt, "delete-object data was null"}; + return Error::JsonParseError("delete-object data was null"); } auto const& del = **result; // Silently skip unknown kinds for forward-compatibility. @@ -161,20 +169,20 @@ FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleEvent( if (event_type == kPayloadTransferred) { if (state_ == State::kInactive) { Reset(); - return FDv2Error{std::nullopt, - "payload-transferred received without an active " - "server-intent"}; + return Error::ProtocolError( + "payload-transferred received without an active " + "server-intent"); } auto result = boost::json::value_to< tl::expected, JsonError>>(data); if (!result) { Reset(); - return FDv2Error{std::nullopt, - "could not deserialize payload-transferred"}; + return Error::JsonParseError( + result.error(), "could not deserialize payload-transferred"); } if (!result->has_value()) { Reset(); - return FDv2Error{std::nullopt, "payload-transferred data was null"}; + return Error::JsonParseError("payload-transferred data was null"); } auto const& transferred = **result; auto type = (state_ == State::kPartial) @@ -194,12 +202,13 @@ FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleEvent( tl::expected, JsonError>>(data); Reset(); if (!result) { - return FDv2Error{std::nullopt, "could not deserialize error event"}; + return Error::JsonParseError(result.error(), + "could not deserialize error event"); } if (!result->has_value()) { - return FDv2Error{std::nullopt, "error event data was null"}; + return Error::JsonParseError("error event data was null"); } - return **result; + return Error::ServerError(std::move(**result)); } if (event_type == kGoodbye) { diff --git a/libs/internal/tests/fdv2_protocol_handler_test.cpp b/libs/internal/tests/fdv2_protocol_handler_test.cpp index fc10ef81d..b9200c44a 100644 --- a/libs/internal/tests/fdv2_protocol_handler_test.cpp +++ b/libs/internal/tests/fdv2_protocol_handler_test.cpp @@ -153,7 +153,7 @@ TEST(FDv2ProtocolHandlerTest, UnknownKindInPutObjectIsSilentlySkipped) { } // ============================================================================ -// error event → discard accumulated data, return FDv2Error +// error event → discard accumulated data, return Error::kServerError // ============================================================================ TEST(FDv2ProtocolHandlerTest, ErrorEventDiscardsAccumulatedDataAndReturnsError) { @@ -167,9 +167,12 @@ TEST(FDv2ProtocolHandlerTest, ErrorEventDiscardsAccumulatedDataAndReturnsError) "error", boost::json::parse(R"({"reason":"something went wrong"})")); - auto* err = std::get_if(&result); + auto* err = std::get_if(&result); ASSERT_NE(err, nullptr); - EXPECT_EQ(err->reason, "something went wrong"); + EXPECT_EQ(err->kind, FDv2ProtocolHandler::Error::Kind::kServerError); + ASSERT_TRUE(err->server_error.has_value()); + EXPECT_EQ(err->server_error->reason, "something went wrong"); + EXPECT_FALSE(err->server_error->id.has_value()); // After the error the handler is reset. A subsequent full transfer should // produce an empty changeset (no leftover data from before the error). @@ -182,6 +185,39 @@ TEST(FDv2ProtocolHandlerTest, ErrorEventDiscardsAccumulatedDataAndReturnsError) EXPECT_TRUE(cs->changes.empty()); } +TEST(FDv2ProtocolHandlerTest, ErrorEventWithIdSetsServerId) { + FDv2ProtocolHandler handler; + + auto result = handler.HandleEvent( + "error", + boost::json::parse(R"({"id":"payload-123","reason":"overloaded"})")); + + auto* err = std::get_if(&result); + ASSERT_NE(err, nullptr); + EXPECT_EQ(err->kind, FDv2ProtocolHandler::Error::Kind::kServerError); + ASSERT_TRUE(err->server_error.has_value()); + ASSERT_TRUE(err->server_error->id.has_value()); + EXPECT_EQ(*err->server_error->id, "payload-123"); + EXPECT_EQ(err->server_error->reason, "overloaded"); +} + +TEST(FDv2ProtocolHandlerTest, MalformedPutObjectReturnsJsonError) { + FDv2ProtocolHandler handler; + + handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); + + // 'object' field is missing required flag fields — deserialisation fails. + auto result = handler.HandleEvent( + "put-object", + boost::json::parse( + R"({"version":1,"kind":"flag","key":"f","object":{}})")); + + auto* err = std::get_if(&result); + ASSERT_NE(err, nullptr); + EXPECT_EQ(err->kind, FDv2ProtocolHandler::Error::Kind::kJsonError); + EXPECT_TRUE(err->json_error.has_value()); +} + // ============================================================================ // goodbye event → return Goodbye // ============================================================================ @@ -282,5 +318,7 @@ TEST(FDv2ProtocolHandlerTest, PayloadTransferredWithoutServerIntentIsError) { auto result = handler.HandleEvent( "payload-transferred", MakePayloadTransferred("s", 1)); - EXPECT_NE(std::get_if(&result), nullptr); + auto* err = std::get_if(&result); + ASSERT_NE(err, nullptr); + EXPECT_EQ(err->kind, FDv2ProtocolHandler::Error::Kind::kProtocolError); } diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp index f4cfd2d02..5a23181c3 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp @@ -204,11 +204,27 @@ FDv2SourceResult FDv2PollingInitializer::HandlePollResult( return FDv2SourceResult{ FDv2SourceResult::Goodbye{goodbye->reason, false}}; } - if (auto* error = std::get_if(&result)) { - std::string msg = "Server error: " + error->reason; - LD_LOG(logger_, LogLevel::kInfo) << kIdentity << ": " << msg; + if (auto* error = + std::get_if(&result)) { + if (error->kind == + FDv2ProtocolHandler::Error::Kind::kServerError) { + auto const& id = error->server_error->id; + std::string msg = + "An issue was encountered receiving updates for " + "payload '" + + id.value_or("") + "' with reason: '" + error->message + + "'. Automatic retry will occur."; + LD_LOG(logger_, LogLevel::kInfo) + << kIdentity << ": " << msg; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kErrorResponse, 0, std::move(msg)), + false}}; + } + LD_LOG(logger_, LogLevel::kError) + << kIdentity << ": " << error->message; return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kUnknown, 0, std::move(msg)), false}}; + MakeError(ErrorKind::kInvalidData, 0, error->message), + false}}; } } diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp index 4e1a3a331..1275890e9 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp @@ -319,11 +319,27 @@ FDv2SourceResult FDv2PollingSynchronizer::HandlePollResult( return FDv2SourceResult{ FDv2SourceResult::Goodbye{goodbye->reason, false}}; } - if (auto* error = std::get_if(&result)) { - std::string msg = "Server error: " + error->reason; - LD_LOG(logger_, LogLevel::kInfo) << kIdentity << ": " << msg; + if (auto* error = + std::get_if(&result)) { + if (error->kind == + FDv2ProtocolHandler::Error::Kind::kServerError) { + auto const& id = error->server_error->id; + std::string msg = + "An issue was encountered receiving updates for " + "payload '" + + id.value_or("") + "' with reason: '" + error->message + + "'. Automatic retry will occur."; + LD_LOG(logger_, LogLevel::kInfo) + << kIdentity << ": " << msg; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kErrorResponse, 0, std::move(msg)), + false}}; + } + LD_LOG(logger_, LogLevel::kError) + << kIdentity << ": " << error->message; return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kUnknown, 0, std::move(msg)), false}}; + MakeError(ErrorKind::kInvalidData, 0, error->message), + false}}; } } From 55e4d8c60b933e4ac7af56f409bcffc8391131cf Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Tue, 7 Apr 2026 14:02:33 -0700 Subject: [PATCH 18/35] refactor shared code into a separate file --- libs/server-sdk/src/CMakeLists.txt | 2 + .../data_systems/fdv2/fdv2_polling_impl.cpp | 194 ++++++++++++++++++ .../data_systems/fdv2/fdv2_polling_impl.hpp | 36 ++++ .../data_systems/fdv2/polling_initializer.cpp | 194 +----------------- .../fdv2/polling_synchronizer.cpp | 175 +--------------- 5 files changed, 244 insertions(+), 357 deletions(-) create mode 100644 libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp create mode 100644 libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp diff --git a/libs/server-sdk/src/CMakeLists.txt b/libs/server-sdk/src/CMakeLists.txt index 2d855d575..09f98465b 100644 --- a/libs/server-sdk/src/CMakeLists.txt +++ b/libs/server-sdk/src/CMakeLists.txt @@ -49,6 +49,8 @@ target_sources(${LIBNAME} data_systems/background_sync/detail/payload_filter_validation/payload_filter_validation.cpp data_systems/background_sync/sources/polling/polling_data_source.hpp data_systems/background_sync/sources/polling/polling_data_source.cpp + data_systems/fdv2/fdv2_polling_impl.hpp + data_systems/fdv2/fdv2_polling_impl.cpp data_systems/fdv2/polling_initializer.hpp data_systems/fdv2/polling_initializer.cpp data_systems/fdv2/polling_synchronizer.hpp diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp new file mode 100644 index 000000000..0f4a446b3 --- /dev/null +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp @@ -0,0 +1,194 @@ +#include "fdv2_polling_impl.hpp" + +#include +#include + +#include + +namespace launchdarkly::server_side::data_systems { + +char const* const kFDv2PollPath = "/sdk/poll"; + +static char const* const kErrorParsingBody = + "Could not parse FDv2 polling response"; +static char const* const kErrorMissingEvents = + "FDv2 polling response missing 'events' array"; +static char const* const kErrorIncompletePayload = + "FDv2 polling response did not contain a complete payload"; + +using data_interfaces::FDv2SourceResult; +using ErrorInfo = FDv2SourceResult::ErrorInfo; +using ErrorKind = ErrorInfo::ErrorKind; + +static ErrorInfo MakeError(ErrorKind kind, + ErrorInfo::StatusCodeType status, + std::string message) { + return ErrorInfo{kind, status, std::move(message), + std::chrono::system_clock::now()}; +} + +network::HttpRequest MakeFDv2PollRequest( + config::built::ServiceEndpoints const& endpoints, + config::built::HttpProperties const& http_properties, + data_model::Selector const& selector, + std::optional const& filter_key) { + auto url = std::make_optional(endpoints.PollingBaseUrl()); + url = network::AppendUrl(url, kFDv2PollPath); + + bool has_query = false; + if (selector.value) { + url->append("?basis=" + selector.value->state); + has_query = true; + } + + if (filter_key) { + url->append(has_query ? "&filter=" : "?filter="); + url->append(*filter_key); + } + + config::builders::HttpPropertiesBuilder const builder(http_properties); + return {url.value_or(""), network::HttpMethod::kGet, builder.Build(), + network::HttpRequest::BodyType{}}; +} + +data_interfaces::FDv2SourceResult HandleFDv2PollResponse( + network::HttpResult const& res, + FDv2ProtocolHandler& protocol_handler, + Logger const& logger, + std::string_view identity) { + if (res.IsError()) { + auto const& msg = res.ErrorMessage(); + std::string error_msg = msg.has_value() ? *msg : "unknown error"; + LD_LOG(logger, LogLevel::kWarn) << identity << ": " << error_msg; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kNetworkError, 0, std::move(error_msg)), + false}}; + } + + if (res.Status() == 304) { + return FDv2SourceResult{FDv2SourceResult::ChangeSet{ + data_model::FDv2ChangeSet{ + data_model::FDv2ChangeSet::Type::kNone, {}, {}}, + false}}; + } + + if (res.Status() == 200) { + auto const& body = res.Body(); + if (!body) { + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, + "polling response contained no body"), + false}}; + } + + boost::system::error_code ec; + auto parsed = boost::json::parse(*body, ec); + if (ec) { + LD_LOG(logger, LogLevel::kError) << kErrorParsingBody; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody), + false}}; + } + + auto const* obj = parsed.if_object(); + if (!obj) { + LD_LOG(logger, LogLevel::kError) << kErrorParsingBody; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody), + false}}; + } + + auto const* events_val = obj->if_contains("events"); + if (!events_val) { + LD_LOG(logger, LogLevel::kError) << kErrorMissingEvents; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), + false}}; + } + + auto const* events_arr = events_val->if_array(); + if (!events_arr) { + LD_LOG(logger, LogLevel::kError) << kErrorMissingEvents; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), + false}}; + } + + for (auto const& event_val : *events_arr) { + auto const* event_obj = event_val.if_object(); + if (!event_obj) { + continue; + } + + auto const* event_type_val = event_obj->if_contains("event"); + auto const* event_data_val = event_obj->if_contains("data"); + if (!event_type_val || !event_data_val) { + continue; + } + + auto const* event_type_str = event_type_val->if_string(); + if (!event_type_str) { + continue; + } + + auto result = protocol_handler.HandleEvent( + std::string_view{event_type_str->data(), + event_type_str->size()}, + *event_data_val); + + if (auto* changeset = + std::get_if(&result)) { + return FDv2SourceResult{ + FDv2SourceResult::ChangeSet{std::move(*changeset), false}}; + } + if (auto* goodbye = std::get_if(&result)) { + return FDv2SourceResult{ + FDv2SourceResult::Goodbye{goodbye->reason, false}}; + } + if (auto* error = + std::get_if(&result)) { + if (error->kind == + FDv2ProtocolHandler::Error::Kind::kServerError) { + auto const& id = error->server_error->id; + std::string msg = + "An issue was encountered receiving updates for " + "payload '" + + id.value_or("") + "' with reason: '" + error->message + + "'. Automatic retry will occur."; + LD_LOG(logger, LogLevel::kInfo) << identity << ": " << msg; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kErrorResponse, 0, std::move(msg)), + false}}; + } + LD_LOG(logger, LogLevel::kError) + << identity << ": " << error->message; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, error->message), + false}}; + } + } + + LD_LOG(logger, LogLevel::kError) << kErrorIncompletePayload; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorIncompletePayload), + false}}; + } + + if (network::IsRecoverableStatus(res.Status())) { + std::string msg = network::ErrorForStatusCode( + res.Status(), "FDv2 polling request", "will retry"); + LD_LOG(logger, LogLevel::kWarn) << identity << ": " << msg; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kErrorResponse, res.Status(), std::move(msg)), + false}}; + } + + std::string msg = network::ErrorForStatusCode( + res.Status(), "FDv2 polling request", std::nullopt); + LD_LOG(logger, LogLevel::kError) << identity << ": " << msg; + return FDv2SourceResult{FDv2SourceResult::TerminalError{ + MakeError(ErrorKind::kErrorResponse, res.Status(), std::move(msg)), + false}}; +} + +} // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp new file mode 100644 index 000000000..e95644768 --- /dev/null +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "../../data_interfaces/source/fdv2_source_result.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace launchdarkly::server_side::data_systems { + +// Path for the FDv2 polling endpoint. +extern char const* const kFDv2PollPath; + +// Build a polling HTTP GET request for the FDv2 endpoint. +network::HttpRequest MakeFDv2PollRequest( + config::built::ServiceEndpoints const& endpoints, + config::built::HttpProperties const& http_properties, + data_model::Selector const& selector, + std::optional const& filter_key); + +// Parse an HTTP response from the FDv2 polling endpoint through the protocol +// handler and return the appropriate result. identity is used in log messages +// to identify the caller (e.g. "FDv2 polling initializer"). +data_interfaces::FDv2SourceResult HandleFDv2PollResponse( + network::HttpResult const& res, + FDv2ProtocolHandler& protocol_handler, + Logger const& logger, + std::string_view identity); + +} // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp index 5a23181c3..d01e00bcc 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp @@ -1,60 +1,15 @@ #include "polling_initializer.hpp" +#include "fdv2_polling_impl.hpp" -#include #include -#include - -#include #include namespace launchdarkly::server_side::data_systems { static char const* const kIdentity = "FDv2 polling initializer"; -static char const* const kFDv2PollPath = "/sdk/poll"; - -static char const* const kErrorParsingBody = - "Could not parse FDv2 polling response"; -static char const* const kErrorMissingEvents = - "FDv2 polling response missing 'events' array"; -static char const* const kErrorIncompletePayload = - "FDv2 polling response did not contain a complete payload"; using data_interfaces::FDv2SourceResult; -using ErrorInfo = FDv2SourceResult::ErrorInfo; -using ErrorKind = ErrorInfo::ErrorKind; - -static ErrorInfo MakeError(ErrorKind kind, - ErrorInfo::StatusCodeType status, - std::string message) { - return ErrorInfo{kind, status, std::move(message), - std::chrono::system_clock::now()}; -} - -static network::HttpRequest MakeRequest( - Logger const& logger, - config::built::ServiceEndpoints const& endpoints, - config::built::HttpProperties const& http_properties, - data_model::Selector const& selector, - std::optional const& filter_key) { - auto url = std::make_optional(endpoints.PollingBaseUrl()); - url = network::AppendUrl(url, kFDv2PollPath); - - bool has_query = false; - if (selector.value) { - url->append("?basis=" + selector.value->state); - has_query = true; - } - - if (filter_key) { - url->append(has_query ? "&filter=" : "?filter="); - url->append(*filter_key); - } - - config::builders::HttpPropertiesBuilder const builder(http_properties); - return {url.value_or(""), network::HttpMethod::kGet, builder.Build(), - network::HttpRequest::BodyType{}}; -} FDv2PollingInitializer::FDv2PollingInitializer( boost::asio::any_io_executor const& executor, @@ -64,19 +19,19 @@ FDv2PollingInitializer::FDv2PollingInitializer( data_model::Selector selector, std::optional filter_key) : logger_(logger), - request_(MakeRequest(logger, - endpoints, - http_properties, - selector, - filter_key)), + request_(MakeFDv2PollRequest(endpoints, http_properties, selector, + filter_key)), requester_(executor, http_properties.Tls()) {} FDv2SourceResult FDv2PollingInitializer::Run() { if (!request_.Valid()) { LD_LOG(logger_, LogLevel::kError) << kIdentity << ": invalid polling endpoint URL"; + using ErrorInfo = FDv2SourceResult::ErrorInfo; return FDv2SourceResult{FDv2SourceResult::TerminalError{ - MakeError(ErrorKind::kUnknown, 0, "invalid polling endpoint URL"), + ErrorInfo{ErrorInfo::ErrorKind::kUnknown, 0, + "invalid polling endpoint URL", + std::chrono::system_clock::now()}, false}}; } @@ -115,140 +70,7 @@ std::string const& FDv2PollingInitializer::Identity() const { FDv2SourceResult FDv2PollingInitializer::HandlePollResult( network::HttpResult const& res) { - if (res.IsError()) { - auto const& msg = res.ErrorMessage(); - std::string error_msg = msg.has_value() ? *msg : "unknown error"; - LD_LOG(logger_, LogLevel::kWarn) << kIdentity << ": " << error_msg; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kNetworkError, 0, std::move(error_msg)), - false}}; - } - - if (res.Status() == 304) { - return FDv2SourceResult{FDv2SourceResult::ChangeSet{ - data_model::FDv2ChangeSet{ - data_model::FDv2ChangeSet::Type::kNone, {}, {}}, - false}}; - } - - if (res.Status() == 200) { - auto const& body = res.Body(); - if (!body) { - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, - "polling response contained no body"), - false}}; - } - - boost::system::error_code ec; - auto parsed = boost::json::parse(*body, ec); - if (ec) { - LD_LOG(logger_, LogLevel::kError) << kErrorParsingBody; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody), - false}}; - } - - auto const* obj = parsed.if_object(); - if (!obj) { - LD_LOG(logger_, LogLevel::kError) << kErrorParsingBody; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody), - false}}; - } - - auto const* events_val = obj->if_contains("events"); - if (!events_val) { - LD_LOG(logger_, LogLevel::kError) << kErrorMissingEvents; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), - false}}; - } - - auto const* events_arr = events_val->if_array(); - if (!events_arr) { - LD_LOG(logger_, LogLevel::kError) << kErrorMissingEvents; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), - false}}; - } - - for (auto const& event_val : *events_arr) { - auto const* event_obj = event_val.if_object(); - if (!event_obj) { - continue; - } - - auto const* event_type_val = event_obj->if_contains("event"); - auto const* event_data_val = event_obj->if_contains("data"); - if (!event_type_val || !event_data_val) { - continue; - } - - auto const* event_type_str = event_type_val->if_string(); - if (!event_type_str) { - continue; - } - - auto result = protocol_handler_.HandleEvent( - std::string_view{event_type_str->data(), - event_type_str->size()}, - *event_data_val); - - if (auto* changeset = - std::get_if(&result)) { - return FDv2SourceResult{ - FDv2SourceResult::ChangeSet{std::move(*changeset), false}}; - } - if (auto* goodbye = std::get_if(&result)) { - return FDv2SourceResult{ - FDv2SourceResult::Goodbye{goodbye->reason, false}}; - } - if (auto* error = - std::get_if(&result)) { - if (error->kind == - FDv2ProtocolHandler::Error::Kind::kServerError) { - auto const& id = error->server_error->id; - std::string msg = - "An issue was encountered receiving updates for " - "payload '" + - id.value_or("") + "' with reason: '" + error->message + - "'. Automatic retry will occur."; - LD_LOG(logger_, LogLevel::kInfo) - << kIdentity << ": " << msg; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kErrorResponse, 0, std::move(msg)), - false}}; - } - LD_LOG(logger_, LogLevel::kError) - << kIdentity << ": " << error->message; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, error->message), - false}}; - } - } - - LD_LOG(logger_, LogLevel::kError) << kErrorIncompletePayload; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorIncompletePayload), - false}}; - } - - if (network::IsRecoverableStatus(res.Status())) { - std::string msg = network::ErrorForStatusCode( - res.Status(), "FDv2 polling request", "will retry"); - LD_LOG(logger_, LogLevel::kWarn) << kIdentity << ": " << msg; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kErrorResponse, res.Status(), std::move(msg)), - false}}; - } - - std::string msg = network::ErrorForStatusCode( - res.Status(), "FDv2 polling request", std::nullopt); - LD_LOG(logger_, LogLevel::kError) << kIdentity << ": " << msg; - return FDv2SourceResult{FDv2SourceResult::TerminalError{ - MakeError(ErrorKind::kErrorResponse, res.Status(), std::move(msg)), - false}}; + return HandleFDv2PollResponse(res, protocol_handler_, logger_, kIdentity); } } // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp index 1275890e9..7edefa676 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp @@ -1,14 +1,12 @@ #include "polling_synchronizer.hpp" +#include "fdv2_polling_impl.hpp" -#include #include -#include #include #include #include #include -#include #include #include @@ -17,28 +15,11 @@ namespace launchdarkly::server_side::data_systems { static char const* const kIdentity = "FDv2 polling synchronizer"; -static char const* const kFDv2PollPath = "/sdk/poll"; - -static char const* const kErrorParsingBody = - "Could not parse FDv2 polling response"; -static char const* const kErrorMissingEvents = - "FDv2 polling response missing 'events' array"; -static char const* const kErrorIncompletePayload = - "FDv2 polling response did not contain a complete payload"; // Minimum polling interval to prevent accidentally hammering the service. static constexpr std::chrono::seconds kMinPollInterval{30}; using data_interfaces::FDv2SourceResult; -using ErrorInfo = FDv2SourceResult::ErrorInfo; -using ErrorKind = ErrorInfo::ErrorKind; - -static ErrorInfo MakeError(ErrorKind kind, - ErrorInfo::StatusCodeType status, - std::string message) { - return ErrorInfo{kind, status, std::move(message), - std::chrono::system_clock::now()}; -} FDv2PollingSynchronizer::FDv2PollingSynchronizer( boost::asio::any_io_executor const& executor, @@ -63,23 +44,8 @@ FDv2PollingSynchronizer::FDv2PollingSynchronizer( network::HttpRequest FDv2PollingSynchronizer::MakeRequest( data_model::Selector const& selector) const { - auto url = std::make_optional(endpoints_.PollingBaseUrl()); - url = network::AppendUrl(url, kFDv2PollPath); - - bool has_query = false; - if (selector.value) { - url->append("?basis=" + selector.value->state); - has_query = true; - } - - if (filter_key_) { - url->append(has_query ? "&filter=" : "?filter="); - url->append(*filter_key_); - } - - config::builders::HttpPropertiesBuilder const builder(http_properties_); - return {url.value_or(""), network::HttpMethod::kGet, builder.Build(), - network::HttpRequest::BodyType{}}; + return MakeFDv2PollRequest(endpoints_, http_properties_, selector, + filter_key_); } FDv2SourceResult FDv2PollingSynchronizer::Next( @@ -230,140 +196,7 @@ std::string const& FDv2PollingSynchronizer::Identity() const { FDv2SourceResult FDv2PollingSynchronizer::HandlePollResult( network::HttpResult const& res) { - if (res.IsError()) { - auto const& msg = res.ErrorMessage(); - std::string error_msg = msg.has_value() ? *msg : "unknown error"; - LD_LOG(logger_, LogLevel::kWarn) << kIdentity << ": " << error_msg; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kNetworkError, 0, std::move(error_msg)), - false}}; - } - - if (res.Status() == 304) { - return FDv2SourceResult{FDv2SourceResult::ChangeSet{ - data_model::FDv2ChangeSet{ - data_model::FDv2ChangeSet::Type::kNone, {}, {}}, - false}}; - } - - if (res.Status() == 200) { - auto const& body = res.Body(); - if (!body) { - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, - "polling response contained no body"), - false}}; - } - - boost::system::error_code ec; - auto parsed = boost::json::parse(*body, ec); - if (ec) { - LD_LOG(logger_, LogLevel::kError) << kErrorParsingBody; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody), - false}}; - } - - auto const* obj = parsed.if_object(); - if (!obj) { - LD_LOG(logger_, LogLevel::kError) << kErrorParsingBody; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody), - false}}; - } - - auto const* events_val = obj->if_contains("events"); - if (!events_val) { - LD_LOG(logger_, LogLevel::kError) << kErrorMissingEvents; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), - false}}; - } - - auto const* events_arr = events_val->if_array(); - if (!events_arr) { - LD_LOG(logger_, LogLevel::kError) << kErrorMissingEvents; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), - false}}; - } - - for (auto const& event_val : *events_arr) { - auto const* event_obj = event_val.if_object(); - if (!event_obj) { - continue; - } - - auto const* event_type_val = event_obj->if_contains("event"); - auto const* event_data_val = event_obj->if_contains("data"); - if (!event_type_val || !event_data_val) { - continue; - } - - auto const* event_type_str = event_type_val->if_string(); - if (!event_type_str) { - continue; - } - - auto result = protocol_handler_.HandleEvent( - std::string_view{event_type_str->data(), - event_type_str->size()}, - *event_data_val); - - if (auto* changeset = - std::get_if(&result)) { - return FDv2SourceResult{ - FDv2SourceResult::ChangeSet{std::move(*changeset), false}}; - } - if (auto* goodbye = std::get_if(&result)) { - return FDv2SourceResult{ - FDv2SourceResult::Goodbye{goodbye->reason, false}}; - } - if (auto* error = - std::get_if(&result)) { - if (error->kind == - FDv2ProtocolHandler::Error::Kind::kServerError) { - auto const& id = error->server_error->id; - std::string msg = - "An issue was encountered receiving updates for " - "payload '" + - id.value_or("") + "' with reason: '" + error->message + - "'. Automatic retry will occur."; - LD_LOG(logger_, LogLevel::kInfo) - << kIdentity << ": " << msg; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kErrorResponse, 0, std::move(msg)), - false}}; - } - LD_LOG(logger_, LogLevel::kError) - << kIdentity << ": " << error->message; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, error->message), - false}}; - } - } - - LD_LOG(logger_, LogLevel::kError) << kErrorIncompletePayload; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorIncompletePayload), - false}}; - } - - if (network::IsRecoverableStatus(res.Status())) { - std::string msg = network::ErrorForStatusCode( - res.Status(), "FDv2 polling request", "will retry"); - LD_LOG(logger_, LogLevel::kWarn) << kIdentity << ": " << msg; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kErrorResponse, res.Status(), std::move(msg)), - false}}; - } - - std::string msg = network::ErrorForStatusCode( - res.Status(), "FDv2 polling request", std::nullopt); - LD_LOG(logger_, LogLevel::kError) << kIdentity << ": " << msg; - return FDv2SourceResult{FDv2SourceResult::TerminalError{ - MakeError(ErrorKind::kErrorResponse, res.Status(), std::move(msg)), - false}}; + return HandleFDv2PollResponse(res, protocol_handler_, logger_, kIdentity); } } // namespace launchdarkly::server_side::data_systems From b8a334cf2cc37fe7574e7d789c099fbd32713a31 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Tue, 7 Apr 2026 14:19:15 -0700 Subject: [PATCH 19/35] refactor FDv2ProtocolHandler::HandleEvent to be clearer --- .../launchdarkly/fdv2_protocol_handler.hpp | 7 + libs/internal/src/fdv2_protocol_handler.cpp | 262 +++++++++--------- 2 files changed, 144 insertions(+), 125 deletions(-) diff --git a/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp b/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp index c1a2bdc24..4caf0c25b 100644 --- a/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp +++ b/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp @@ -102,6 +102,13 @@ class FDv2ProtocolHandler { private: enum class State { kInactive, kFull, kPartial }; + Result HandleServerIntent(boost::json::value const& data); + Result HandlePutObject(boost::json::value const& data); + Result HandleDeleteObject(boost::json::value const& data); + Result HandlePayloadTransferred(boost::json::value const& data); + Result HandleError(boost::json::value const& data); + Result HandleGoodbye(boost::json::value const& data); + State state_ = State::kInactive; std::vector changes_; }; diff --git a/libs/internal/src/fdv2_protocol_handler.cpp b/libs/internal/src/fdv2_protocol_handler.cpp index 89a7c54c2..c365b5ae0 100644 --- a/libs/internal/src/fdv2_protocol_handler.cpp +++ b/libs/internal/src/fdv2_protocol_handler.cpp @@ -84,147 +84,159 @@ static data_model::FDv2Change MakeDeleteChange(DeleteObject const& del) { FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleEvent( std::string_view event_type, boost::json::value const& data) { - if (event_type == kServerIntent) { - auto result = boost::json::value_to< - tl::expected, JsonError>>(data); - if (!result) { - Reset(); - return Error::JsonParseError(result.error(), - "could not deserialize server-intent"); - } - if (!result->has_value()) { - Reset(); - return Error::JsonParseError("server-intent data was null"); - } - auto const& intent = **result; - if (intent.payloads.empty()) { - return std::monostate{}; - } - auto const& code = intent.payloads[0].intent_code; - changes_.clear(); - if (code == IntentCode::kTransferFull) { - state_ = State::kFull; - } else if (code == IntentCode::kTransferChanges) { - state_ = State::kPartial; - } else { - // kNone or kUnknown: emit an empty changeset immediately. - state_ = State::kInactive; - return data_model::FDv2ChangeSet{data_model::FDv2ChangeSet::Type::kNone, - {}, - data_model::Selector{}}; - } + if (event_type == kServerIntent) { return HandleServerIntent(data); } + if (event_type == kPutObject) { return HandlePutObject(data); } + if (event_type == kDeleteObject) { return HandleDeleteObject(data); } + if (event_type == kPayloadTransferred) { return HandlePayloadTransferred(data); } + if (event_type == kError) { return HandleError(data); } + if (event_type == kGoodbye) { return HandleGoodbye(data); } + // heartbeat and unrecognized events: no-op. + return std::monostate{}; +} + +FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleServerIntent( + boost::json::value const& data) { + auto result = boost::json::value_to< + tl::expected, JsonError>>(data); + if (!result) { + Reset(); + return Error::JsonParseError(result.error(), + "could not deserialize server-intent"); + } + if (!result->has_value()) { + Reset(); + return Error::JsonParseError("server-intent data was null"); + } + auto const& intent = **result; + if (intent.payloads.empty()) { return std::monostate{}; } + auto const& code = intent.payloads[0].intent_code; + changes_.clear(); + if (code == IntentCode::kTransferFull) { + state_ = State::kFull; + } else if (code == IntentCode::kTransferChanges) { + state_ = State::kPartial; + } else { + // kNone or kUnknown: emit an empty changeset immediately. + state_ = State::kInactive; + return data_model::FDv2ChangeSet{data_model::FDv2ChangeSet::Type::kNone, + {}, + data_model::Selector{}}; + } + return std::monostate{}; +} - if (event_type == kPutObject) { - if (state_ == State::kInactive) { - return std::monostate{}; - } - auto result = boost::json::value_to< - tl::expected, JsonError>>(data); - if (!result) { - Reset(); - return Error::JsonParseError(result.error(), - "could not deserialize put-object"); - } - if (!result->has_value()) { - Reset(); - return Error::JsonParseError("put-object data was null"); - } - auto change = ParsePut(**result); - if (!change) { - Reset(); - return std::move(change.error()); - } - if (*change) { - changes_.push_back(std::move(**change)); - } +FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandlePutObject( + boost::json::value const& data) { + if (state_ == State::kInactive) { return std::monostate{}; } + auto result = boost::json::value_to< + tl::expected, JsonError>>(data); + if (!result) { + Reset(); + return Error::JsonParseError(result.error(), + "could not deserialize put-object"); + } + if (!result->has_value()) { + Reset(); + return Error::JsonParseError("put-object data was null"); + } + auto change = ParsePut(**result); + if (!change) { + Reset(); + return std::move(change.error()); + } + if (*change) { + changes_.push_back(std::move(**change)); + } + return std::monostate{}; +} - if (event_type == kDeleteObject) { - if (state_ == State::kInactive) { - return std::monostate{}; - } - auto result = boost::json::value_to< - tl::expected, JsonError>>(data); - if (!result) { - Reset(); - return Error::JsonParseError(result.error(), - "could not deserialize delete-object"); - } - if (!result->has_value()) { - Reset(); - return Error::JsonParseError("delete-object data was null"); - } - auto const& del = **result; - // Silently skip unknown kinds for forward-compatibility. - if (del.kind != "flag" && del.kind != "segment") { - return std::monostate{}; - } - changes_.push_back(MakeDeleteChange(del)); +FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleDeleteObject( + boost::json::value const& data) { + if (state_ == State::kInactive) { return std::monostate{}; } - - if (event_type == kPayloadTransferred) { - if (state_ == State::kInactive) { - Reset(); - return Error::ProtocolError( - "payload-transferred received without an active " - "server-intent"); - } - auto result = boost::json::value_to< - tl::expected, JsonError>>(data); - if (!result) { - Reset(); - return Error::JsonParseError( - result.error(), "could not deserialize payload-transferred"); - } - if (!result->has_value()) { - Reset(); - return Error::JsonParseError("payload-transferred data was null"); - } - auto const& transferred = **result; - auto type = (state_ == State::kPartial) - ? data_model::FDv2ChangeSet::Type::kPartial - : data_model::FDv2ChangeSet::Type::kFull; - data_model::FDv2ChangeSet changeset{ - type, - std::move(changes_), - data_model::Selector{data_model::Selector::State{ - transferred.version, transferred.state}}}; + auto result = boost::json::value_to< + tl::expected, JsonError>>(data); + if (!result) { Reset(); - return changeset; + return Error::JsonParseError(result.error(), + "could not deserialize delete-object"); } + if (!result->has_value()) { + Reset(); + return Error::JsonParseError("delete-object data was null"); + } + auto const& del = **result; + // Silently skip unknown kinds for forward-compatibility. + if (del.kind != "flag" && del.kind != "segment") { + return std::monostate{}; + } + changes_.push_back(MakeDeleteChange(del)); + return std::monostate{}; +} - if (event_type == kError) { - auto result = boost::json::value_to< - tl::expected, JsonError>>(data); +FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandlePayloadTransferred( + boost::json::value const& data) { + if (state_ == State::kInactive) { Reset(); - if (!result) { - return Error::JsonParseError(result.error(), - "could not deserialize error event"); - } - if (!result->has_value()) { - return Error::JsonParseError("error event data was null"); - } - return Error::ServerError(std::move(**result)); + return Error::ProtocolError( + "payload-transferred received without an active " + "server-intent"); } + auto result = boost::json::value_to< + tl::expected, JsonError>>(data); + if (!result) { + Reset(); + return Error::JsonParseError( + result.error(), "could not deserialize payload-transferred"); + } + if (!result->has_value()) { + Reset(); + return Error::JsonParseError("payload-transferred data was null"); + } + auto const& transferred = **result; + auto type = (state_ == State::kPartial) + ? data_model::FDv2ChangeSet::Type::kPartial + : data_model::FDv2ChangeSet::Type::kFull; + data_model::FDv2ChangeSet changeset{ + type, + std::move(changes_), + data_model::Selector{data_model::Selector::State{ + transferred.version, transferred.state}}}; + Reset(); + return changeset; +} - if (event_type == kGoodbye) { - auto result = boost::json::value_to< - tl::expected, JsonError>>(data); - if (!result) { - return Goodbye{std::nullopt}; - } - if (!result->has_value()) { - return Goodbye{std::nullopt}; - } - return **result; +FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleError( + boost::json::value const& data) { + auto result = boost::json::value_to< + tl::expected, JsonError>>(data); + Reset(); + if (!result) { + return Error::JsonParseError(result.error(), + "could not deserialize error event"); } + if (!result->has_value()) { + return Error::JsonParseError("error event data was null"); + } + return Error::ServerError(std::move(**result)); +} - // heartbeat and unrecognized events: no-op. - return std::monostate{}; +FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleGoodbye( + boost::json::value const& data) { + auto result = boost::json::value_to< + tl::expected, JsonError>>(data); + if (!result) { + return Goodbye{std::nullopt}; + } + if (!result->has_value()) { + return Goodbye{std::nullopt}; + } + return **result; } void FDv2ProtocolHandler::Reset() { From 6e5b1d40759871bc247e27b50c5a65493a561292 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Tue, 7 Apr 2026 14:20:40 -0700 Subject: [PATCH 20/35] clang format --- .../launchdarkly/fdv2_protocol_handler.hpp | 6 +-- libs/internal/src/fdv2_protocol_handler.cpp | 54 +++++++++++-------- .../data_systems/fdv2/polling_initializer.cpp | 4 +- 3 files changed, 37 insertions(+), 27 deletions(-) diff --git a/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp b/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp index 4caf0c25b..11cfa7e4e 100644 --- a/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp +++ b/libs/internal/include/launchdarkly/fdv2_protocol_handler.hpp @@ -75,10 +75,8 @@ class FDv2ProtocolHandler { * server-sent error event) * - Goodbye: server is closing; caller should rotate sources */ - using Result = std::variant; + using Result = + std::variant; /** * Process one FDv2 event. diff --git a/libs/internal/src/fdv2_protocol_handler.cpp b/libs/internal/src/fdv2_protocol_handler.cpp index c365b5ae0..c8fbaa637 100644 --- a/libs/internal/src/fdv2_protocol_handler.cpp +++ b/libs/internal/src/fdv2_protocol_handler.cpp @@ -23,8 +23,8 @@ using Error = FDv2ProtocolHandler::Error; // Returns the parsed FDv2Change on success, nullopt for unknown kinds (which // should be silently skipped for forward-compatibility), or an Error if // a known kind fails to deserialize. -static tl::expected, Error> -ParsePut(PutObject const& put) { +static tl::expected, Error> ParsePut( + PutObject const& put) { if (put.kind == "flag") { auto result = boost::json::value_to< tl::expected, JsonError>>( @@ -37,8 +37,8 @@ ParsePut(PutObject const& put) { "could not deserialize flag '" + put.key + "'")); } if (!result->has_value()) { - return tl::make_unexpected( - Error::JsonParseError("flag '" + put.key + "' object was null")); + return tl::make_unexpected(Error::JsonParseError( + "flag '" + put.key + "' object was null")); } return data_model::FDv2Change{ put.key, @@ -60,9 +60,8 @@ ParsePut(PutObject const& put) { "segment '" + put.key + "' object was null")); } return data_model::FDv2Change{ - put.key, - data_model::ItemDescriptor{ - std::move(**result)}}; + put.key, data_model::ItemDescriptor{ + std::move(**result)}}; } // Silently skip unknown kinds for forward-compatibility. return std::nullopt; @@ -84,12 +83,24 @@ static data_model::FDv2Change MakeDeleteChange(DeleteObject const& del) { FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleEvent( std::string_view event_type, boost::json::value const& data) { - if (event_type == kServerIntent) { return HandleServerIntent(data); } - if (event_type == kPutObject) { return HandlePutObject(data); } - if (event_type == kDeleteObject) { return HandleDeleteObject(data); } - if (event_type == kPayloadTransferred) { return HandlePayloadTransferred(data); } - if (event_type == kError) { return HandleError(data); } - if (event_type == kGoodbye) { return HandleGoodbye(data); } + if (event_type == kServerIntent) { + return HandleServerIntent(data); + } + if (event_type == kPutObject) { + return HandlePutObject(data); + } + if (event_type == kDeleteObject) { + return HandleDeleteObject(data); + } + if (event_type == kPayloadTransferred) { + return HandlePayloadTransferred(data); + } + if (event_type == kError) { + return HandleError(data); + } + if (event_type == kGoodbye) { + return HandleGoodbye(data); + } // heartbeat and unrecognized events: no-op. return std::monostate{}; } @@ -120,9 +131,8 @@ FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleServerIntent( } else { // kNone or kUnknown: emit an empty changeset immediately. state_ = State::kInactive; - return data_model::FDv2ChangeSet{data_model::FDv2ChangeSet::Type::kNone, - {}, - data_model::Selector{}}; + return data_model::FDv2ChangeSet{ + data_model::FDv2ChangeSet::Type::kNone, {}, data_model::Selector{}}; } return std::monostate{}; } @@ -203,10 +213,9 @@ FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandlePayloadTransferred( ? data_model::FDv2ChangeSet::Type::kPartial : data_model::FDv2ChangeSet::Type::kFull; data_model::FDv2ChangeSet changeset{ - type, - std::move(changes_), - data_model::Selector{data_model::Selector::State{ - transferred.version, transferred.state}}}; + type, std::move(changes_), + data_model::Selector{data_model::Selector::State{transferred.version, + transferred.state}}}; Reset(); return changeset; } @@ -228,8 +237,9 @@ FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleError( FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleGoodbye( boost::json::value const& data) { - auto result = boost::json::value_to< - tl::expected, JsonError>>(data); + auto result = + boost::json::value_to, JsonError>>( + data); if (!result) { return Goodbye{std::nullopt}; } diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp index d01e00bcc..f9b2a1d44 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp @@ -19,7 +19,9 @@ FDv2PollingInitializer::FDv2PollingInitializer( data_model::Selector selector, std::optional filter_key) : logger_(logger), - request_(MakeFDv2PollRequest(endpoints, http_properties, selector, + request_(MakeFDv2PollRequest(endpoints, + http_properties, + selector, filter_key)), requester_(executor, http_properties.Tls()) {} From 1e2d43867a474fb30650d00c1d699041c10fb029 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Tue, 7 Apr 2026 14:26:55 -0700 Subject: [PATCH 21/35] handle closed first in network response --- .../src/data_systems/fdv2/polling_synchronizer.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp index 7edefa676..439f4a24e 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp @@ -167,11 +167,11 @@ void FDv2PollingSynchronizer::DoPoll( [this, promise](std::array order, network::HttpResult poll_result, boost::system::error_code) mutable { - if (order[0] == 0) { - promise->set_value(HandlePollResult(poll_result)); - } else if (closed_) { + if (closed_) { promise->set_value( FDv2SourceResult{FDv2SourceResult::Shutdown{}}); + } else if (order[0] == 0) { + promise->set_value(HandlePollResult(poll_result)); } else { promise->set_value( FDv2SourceResult{FDv2SourceResult::Timeout{}}); From 01cc26b879c23d6e30895878be8c8f95045cf5c6 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Tue, 7 Apr 2026 14:28:42 -0700 Subject: [PATCH 22/35] handle url parsing failure --- libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp index 0f4a446b3..6392f3af6 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp @@ -36,12 +36,12 @@ network::HttpRequest MakeFDv2PollRequest( url = network::AppendUrl(url, kFDv2PollPath); bool has_query = false; - if (selector.value) { + if (selector.value && url) { url->append("?basis=" + selector.value->state); has_query = true; } - if (filter_key) { + if (filter_key && url) { url->append(has_query ? "&filter=" : "?filter="); url->append(*filter_key); } From 7a60c2adc92cb32815167923f663ba99c6d3ac10 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Tue, 7 Apr 2026 16:26:17 -0700 Subject: [PATCH 23/35] minor cleanup and add tests --- libs/internal/src/fdv2_protocol_handler.cpp | 2 + .../tests/fdv2_protocol_handler_test.cpp | 43 +++++++++++++++++++ .../data_systems/fdv2/fdv2_polling_impl.cpp | 30 +++++++------ .../data_systems/fdv2/fdv2_polling_impl.hpp | 3 -- .../fdv2/polling_synchronizer.cpp | 6 +-- .../fdv2/polling_synchronizer.hpp | 2 +- 6 files changed, 66 insertions(+), 20 deletions(-) diff --git a/libs/internal/src/fdv2_protocol_handler.cpp b/libs/internal/src/fdv2_protocol_handler.cpp index c8fbaa637..458409edd 100644 --- a/libs/internal/src/fdv2_protocol_handler.cpp +++ b/libs/internal/src/fdv2_protocol_handler.cpp @@ -122,6 +122,7 @@ FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleServerIntent( if (intent.payloads.empty()) { return std::monostate{}; } + // The protocol defines exactly one payload per intent. auto const& code = intent.payloads[0].intent_code; changes_.clear(); if (code == IntentCode::kTransferFull) { @@ -237,6 +238,7 @@ FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleError( FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleGoodbye( boost::json::value const& data) { + Reset(); auto result = boost::json::value_to, JsonError>>( data); diff --git a/libs/internal/tests/fdv2_protocol_handler_test.cpp b/libs/internal/tests/fdv2_protocol_handler_test.cpp index b9200c44a..72a7bb2ed 100644 --- a/libs/internal/tests/fdv2_protocol_handler_test.cpp +++ b/libs/internal/tests/fdv2_protocol_handler_test.cpp @@ -106,6 +106,32 @@ TEST(FDv2ProtocolHandlerTest, FullIntentAccumulatesMultipleObjects) { EXPECT_EQ(cs->changes.size(), 3u); } +TEST(FDv2ProtocolHandlerTest, SecondServerIntentMidTransferDiscardsAccumulatedChanges) { + FDv2ProtocolHandler handler; + + // Start a full transfer and accumulate a change. + handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); + handler.HandleEvent("put-object", + MakePutObject("flag", "flag-1", kFlagJson)); + + // A second server-intent arrives before payload-transferred. + // The first transfer's accumulated changes should be discarded. + auto r = handler.HandleEvent("server-intent", MakeServerIntent("xfer-changes")); + EXPECT_TRUE(std::holds_alternative(r)); + + // Only the flag from the second transfer should appear in the changeset. + handler.HandleEvent("put-object", + MakePutObject("flag", "flag-2", kFlagJson)); + auto result = handler.HandleEvent( + "payload-transferred", MakePayloadTransferred("state-new", 2)); + + auto* cs = std::get_if(&result); + ASSERT_NE(cs, nullptr); + EXPECT_EQ(cs->type, data_model::FDv2ChangeSet::Type::kPartial); + ASSERT_EQ(cs->changes.size(), 1u); + EXPECT_EQ(cs->changes[0].key, "flag-2"); +} + // ============================================================================ // kTransferChanges intent // ============================================================================ @@ -152,6 +178,23 @@ TEST(FDv2ProtocolHandlerTest, UnknownKindInPutObjectIsSilentlySkipped) { EXPECT_EQ(cs->changes[0].key, "my-flag"); } +TEST(FDv2ProtocolHandlerTest, UnknownKindInDeleteObjectIsSilentlySkipped) { + FDv2ProtocolHandler handler; + + handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); + handler.HandleEvent("delete-object", MakeDeleteObject("experiment", "exp-1", 1)); + handler.HandleEvent("delete-object", MakeDeleteObject("flag", "my-flag", 2)); + + auto result = handler.HandleEvent( + "payload-transferred", MakePayloadTransferred("s", 1)); + + auto* cs = std::get_if(&result); + ASSERT_NE(cs, nullptr); + // Only the known kind (flag) should appear. + EXPECT_EQ(cs->changes.size(), 1u); + EXPECT_EQ(cs->changes[0].key, "my-flag"); +} + // ============================================================================ // error event → discard accumulated data, return Error::kServerError // ============================================================================ diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp index 6392f3af6..0c1df19ec 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp @@ -4,10 +4,12 @@ #include #include +#include +#include namespace launchdarkly::server_side::data_systems { -char const* const kFDv2PollPath = "/sdk/poll"; +static char const* const kFDv2PollPath = "/sdk/poll"; static char const* const kErrorParsingBody = "Could not parse FDv2 polling response"; @@ -32,22 +34,24 @@ network::HttpRequest MakeFDv2PollRequest( config::built::HttpProperties const& http_properties, data_model::Selector const& selector, std::optional const& filter_key) { - auto url = std::make_optional(endpoints.PollingBaseUrl()); - url = network::AppendUrl(url, kFDv2PollPath); + config::builders::HttpPropertiesBuilder const builder(http_properties); - bool has_query = false; - if (selector.value && url) { - url->append("?basis=" + selector.value->state); - has_query = true; + auto parsed = boost::urls::parse_uri(endpoints.PollingBaseUrl()); + if (!parsed) { + return {"", network::HttpMethod::kGet, builder.Build(), + network::HttpRequest::BodyType{}}; } - if (filter_key && url) { - url->append(has_query ? "&filter=" : "?filter="); - url->append(*filter_key); + boost::urls::url u = parsed.value(); + u.set_path(u.path() + kFDv2PollPath); + if (selector.value) { + u.params().append({"basis", selector.value->state}); + } + if (filter_key) { + u.params().append({"filter", *filter_key}); } - config::builders::HttpPropertiesBuilder const builder(http_properties); - return {url.value_or(""), network::HttpMethod::kGet, builder.Build(), + return {std::string(u.buffer()), network::HttpMethod::kGet, builder.Build(), network::HttpRequest::BodyType{}}; } @@ -149,7 +153,7 @@ data_interfaces::FDv2SourceResult HandleFDv2PollResponse( std::get_if(&result)) { if (error->kind == FDv2ProtocolHandler::Error::Kind::kServerError) { - auto const& id = error->server_error->id; + auto const& id = error->server_error.value().id; std::string msg = "An issue was encountered receiving updates for " "payload '" + diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp index e95644768..fecf79243 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp @@ -14,9 +14,6 @@ namespace launchdarkly::server_side::data_systems { -// Path for the FDv2 polling endpoint. -extern char const* const kFDv2PollPath; - // Build a polling HTTP GET request for the FDv2 endpoint. network::HttpRequest MakeFDv2PollRequest( config::built::ServiceEndpoints const& endpoints, diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp index 439f4a24e..6aa6a89a7 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp @@ -130,19 +130,19 @@ void FDv2PollingSynchronizer::DoNext( FDv2SourceResult::Timeout{}}); return; } - DoPoll(deadline, std::move(selector), + DoPoll(deadline, selector, std::move(promise)); })); return; } } - DoPoll(deadline, std::move(selector), std::move(promise)); + DoPoll(deadline, selector, std::move(promise)); } void FDv2PollingSynchronizer::DoPoll( std::chrono::time_point deadline, - data_model::Selector selector, + data_model::Selector const& selector, std::shared_ptr> promise) { if (closed_) { promise->set_value(FDv2SourceResult{FDv2SourceResult::Shutdown{}}); diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp index a530589c6..5b2ff5207 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp @@ -65,7 +65,7 @@ class FDv2PollingSynchronizer final // Called on the ASIO thread. Fires the HTTP request and waits for the // response or timeout, then sets the promise. void DoPoll(std::chrono::time_point deadline, - data_model::Selector selector, + data_model::Selector const& selector, std::shared_ptr> promise); From c8e736f74efa8b3baf027c3f5bd288902f6cc0de Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Tue, 7 Apr 2026 16:28:00 -0700 Subject: [PATCH 24/35] clang-format --- .../launchdarkly/network/asio_requester.hpp | 13 +-- .../tests/fdv2_protocol_handler_test.cpp | 86 ++++++++++--------- .../fdv2/polling_synchronizer.cpp | 42 ++++----- 3 files changed, 74 insertions(+), 67 deletions(-) diff --git a/libs/internal/include/launchdarkly/network/asio_requester.hpp b/libs/internal/include/launchdarkly/network/asio_requester.hpp index 1adb64e49..580ebbdb1 100644 --- a/libs/internal/include/launchdarkly/network/asio_requester.hpp +++ b/libs/internal/include/launchdarkly/network/asio_requester.hpp @@ -285,11 +285,12 @@ class AsioRequester { auto Request(HttpRequest request, CompletionToken&& token) { return boost::asio::async_initiate( [this](auto handler, HttpRequest req) { - InnerRequest(net::make_strand(ctx_), std::move(req), - [h = std::move(handler)](HttpResult result) mutable { - std::move(h)(std::move(result)); - }, - 0); + InnerRequest( + net::make_strand(ctx_), std::move(req), + [h = std::move(handler)](HttpResult result) mutable { + std::move(h)(std::move(result)); + }, + 0); }, token, std::move(request)); } @@ -329,7 +330,7 @@ class AsioRequester { redirect_count]() mutable { auto beast_request = MakeBeastRequest(*request); - const auto& properties = request->Properties(); + auto const& properties = request->Properties(); std::string service = request->Port().value_or(request->Https() ? "https" : "http"); diff --git a/libs/internal/tests/fdv2_protocol_handler_test.cpp b/libs/internal/tests/fdv2_protocol_handler_test.cpp index 72a7bb2ed..4a7d9da0d 100644 --- a/libs/internal/tests/fdv2_protocol_handler_test.cpp +++ b/libs/internal/tests/fdv2_protocol_handler_test.cpp @@ -26,8 +26,8 @@ static boost::json::value MakePutObject(std::string const& kind, std::string const& key, std::string const& object_json) { return boost::json::parse(R"({"version":1,"kind":")" + kind + - R"(","key":")" + key + - R"(","object":)" + object_json + "}"); + R"(","key":")" + key + R"(","object":)" + + object_json + "}"); } static boost::json::value MakeDeleteObject(std::string const& kind, @@ -51,7 +51,8 @@ static boost::json::value MakePayloadTransferred(std::string const& state, TEST(FDv2ProtocolHandlerTest, NoneIntentEmitsEmptyChangeSetImmediately) { FDv2ProtocolHandler handler; - auto result = handler.HandleEvent("server-intent", MakeServerIntent("none")); + auto result = + handler.HandleEvent("server-intent", MakeServerIntent("none")); auto* cs = std::get_if(&result); ASSERT_NE(cs, nullptr); @@ -67,15 +68,16 @@ TEST(FDv2ProtocolHandlerTest, NoneIntentEmitsEmptyChangeSetImmediately) { TEST(FDv2ProtocolHandlerTest, FullIntentEmitsChangeSetOnPayloadTransferred) { FDv2ProtocolHandler handler; - auto r1 = handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); + auto r1 = + handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); EXPECT_TRUE(std::holds_alternative(r1)); - auto r2 = handler.HandleEvent( - "put-object", MakePutObject("flag", "my-flag", kFlagJson)); + auto r2 = handler.HandleEvent("put-object", + MakePutObject("flag", "my-flag", kFlagJson)); EXPECT_TRUE(std::holds_alternative(r2)); - auto r3 = handler.HandleEvent( - "payload-transferred", MakePayloadTransferred("state-abc", 7)); + auto r3 = handler.HandleEvent("payload-transferred", + MakePayloadTransferred("state-abc", 7)); auto* cs = std::get_if(&r3); ASSERT_NE(cs, nullptr); @@ -95,10 +97,11 @@ TEST(FDv2ProtocolHandlerTest, FullIntentAccumulatesMultipleObjects) { MakePutObject("flag", "flag-1", kFlagJson)); handler.HandleEvent("put-object", MakePutObject("flag", "flag-2", kFlagJson)); - handler.HandleEvent("delete-object", MakeDeleteObject("segment", "seg-1", 5)); + handler.HandleEvent("delete-object", + MakeDeleteObject("segment", "seg-1", 5)); - auto result = handler.HandleEvent( - "payload-transferred", MakePayloadTransferred("s", 1)); + auto result = handler.HandleEvent("payload-transferred", + MakePayloadTransferred("s", 1)); auto* cs = std::get_if(&result); ASSERT_NE(cs, nullptr); @@ -106,7 +109,8 @@ TEST(FDv2ProtocolHandlerTest, FullIntentAccumulatesMultipleObjects) { EXPECT_EQ(cs->changes.size(), 3u); } -TEST(FDv2ProtocolHandlerTest, SecondServerIntentMidTransferDiscardsAccumulatedChanges) { +TEST(FDv2ProtocolHandlerTest, + SecondServerIntentMidTransferDiscardsAccumulatedChanges) { FDv2ProtocolHandler handler; // Start a full transfer and accumulate a change. @@ -116,14 +120,15 @@ TEST(FDv2ProtocolHandlerTest, SecondServerIntentMidTransferDiscardsAccumulatedCh // A second server-intent arrives before payload-transferred. // The first transfer's accumulated changes should be discarded. - auto r = handler.HandleEvent("server-intent", MakeServerIntent("xfer-changes")); + auto r = + handler.HandleEvent("server-intent", MakeServerIntent("xfer-changes")); EXPECT_TRUE(std::holds_alternative(r)); // Only the flag from the second transfer should appear in the changeset. handler.HandleEvent("put-object", MakePutObject("flag", "flag-2", kFlagJson)); - auto result = handler.HandleEvent( - "payload-transferred", MakePayloadTransferred("state-new", 2)); + auto result = handler.HandleEvent("payload-transferred", + MakePayloadTransferred("state-new", 2)); auto* cs = std::get_if(&result); ASSERT_NE(cs, nullptr); @@ -143,8 +148,8 @@ TEST(FDv2ProtocolHandlerTest, PartialIntentEmitsPartialChangeSet) { handler.HandleEvent("put-object", MakePutObject("segment", "my-seg", kSegmentJson)); - auto result = handler.HandleEvent( - "payload-transferred", MakePayloadTransferred("state-xyz", 3)); + auto result = handler.HandleEvent("payload-transferred", + MakePayloadTransferred("state-xyz", 3)); auto* cs = std::get_if(&result); ASSERT_NE(cs, nullptr); @@ -163,13 +168,14 @@ TEST(FDv2ProtocolHandlerTest, UnknownKindInPutObjectIsSilentlySkipped) { FDv2ProtocolHandler handler; handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); - handler.HandleEvent("put-object", - MakePutObject("experiment", "exp-1", R"({"key":"exp-1","version":1})")); + handler.HandleEvent( + "put-object", + MakePutObject("experiment", "exp-1", R"({"key":"exp-1","version":1})")); handler.HandleEvent("put-object", MakePutObject("flag", "my-flag", kFlagJson)); - auto result = handler.HandleEvent( - "payload-transferred", MakePayloadTransferred("s", 1)); + auto result = handler.HandleEvent("payload-transferred", + MakePayloadTransferred("s", 1)); auto* cs = std::get_if(&result); ASSERT_NE(cs, nullptr); @@ -182,11 +188,13 @@ TEST(FDv2ProtocolHandlerTest, UnknownKindInDeleteObjectIsSilentlySkipped) { FDv2ProtocolHandler handler; handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); - handler.HandleEvent("delete-object", MakeDeleteObject("experiment", "exp-1", 1)); - handler.HandleEvent("delete-object", MakeDeleteObject("flag", "my-flag", 2)); + handler.HandleEvent("delete-object", + MakeDeleteObject("experiment", "exp-1", 1)); + handler.HandleEvent("delete-object", + MakeDeleteObject("flag", "my-flag", 2)); - auto result = handler.HandleEvent( - "payload-transferred", MakePayloadTransferred("s", 1)); + auto result = handler.HandleEvent("payload-transferred", + MakePayloadTransferred("s", 1)); auto* cs = std::get_if(&result); ASSERT_NE(cs, nullptr); @@ -199,7 +207,8 @@ TEST(FDv2ProtocolHandlerTest, UnknownKindInDeleteObjectIsSilentlySkipped) { // error event → discard accumulated data, return Error::kServerError // ============================================================================ -TEST(FDv2ProtocolHandlerTest, ErrorEventDiscardsAccumulatedDataAndReturnsError) { +TEST(FDv2ProtocolHandlerTest, + ErrorEventDiscardsAccumulatedDataAndReturnsError) { FDv2ProtocolHandler handler; handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); @@ -207,8 +216,7 @@ TEST(FDv2ProtocolHandlerTest, ErrorEventDiscardsAccumulatedDataAndReturnsError) MakePutObject("flag", "my-flag", kFlagJson)); auto result = handler.HandleEvent( - "error", - boost::json::parse(R"({"reason":"something went wrong"})")); + "error", boost::json::parse(R"({"reason":"something went wrong"})")); auto* err = std::get_if(&result); ASSERT_NE(err, nullptr); @@ -220,8 +228,8 @@ TEST(FDv2ProtocolHandlerTest, ErrorEventDiscardsAccumulatedDataAndReturnsError) // After the error the handler is reset. A subsequent full transfer should // produce an empty changeset (no leftover data from before the error). handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); - auto result2 = handler.HandleEvent( - "payload-transferred", MakePayloadTransferred("s", 1)); + auto result2 = handler.HandleEvent("payload-transferred", + MakePayloadTransferred("s", 1)); auto* cs = std::get_if(&result2); ASSERT_NE(cs, nullptr); @@ -269,8 +277,7 @@ TEST(FDv2ProtocolHandlerTest, GoodbyeEventReturnsGoodbye) { FDv2ProtocolHandler handler; auto result = handler.HandleEvent( - "goodbye", - boost::json::parse(R"({"reason":"shutting down"})")); + "goodbye", boost::json::parse(R"({"reason":"shutting down"})")); auto* gb = std::get_if(&result); ASSERT_NE(gb, nullptr); @@ -295,8 +302,7 @@ TEST(FDv2ProtocolHandlerTest, GoodbyeWithoutReasonReturnsGoodbye) { TEST(FDv2ProtocolHandlerTest, HeartbeatReturnsMonostate) { FDv2ProtocolHandler handler; - auto result = - handler.HandleEvent("heartbeat", boost::json::parse(R"({})")); + auto result = handler.HandleEvent("heartbeat", boost::json::parse(R"({})")); EXPECT_TRUE(std::holds_alternative(result)); } @@ -324,8 +330,8 @@ TEST(FDv2ProtocolHandlerTest, PutBeforeServerIntentIsIgnored) { EXPECT_TRUE(std::holds_alternative(r1)); handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); - auto result = handler.HandleEvent( - "payload-transferred", MakePayloadTransferred("s", 1)); + auto result = handler.HandleEvent("payload-transferred", + MakePayloadTransferred("s", 1)); auto* cs = std::get_if(&result); ASSERT_NE(cs, nullptr); @@ -347,8 +353,8 @@ TEST(FDv2ProtocolHandlerTest, ResetClearsState) { // After reset, payload-transferred with no prior server-intent produces // a full changeset with no changes. handler.HandleEvent("server-intent", MakeServerIntent("xfer-full")); - auto result = handler.HandleEvent( - "payload-transferred", MakePayloadTransferred("s", 1)); + auto result = handler.HandleEvent("payload-transferred", + MakePayloadTransferred("s", 1)); auto* cs = std::get_if(&result); ASSERT_NE(cs, nullptr); @@ -358,8 +364,8 @@ TEST(FDv2ProtocolHandlerTest, ResetClearsState) { TEST(FDv2ProtocolHandlerTest, PayloadTransferredWithoutServerIntentIsError) { FDv2ProtocolHandler handler; - auto result = handler.HandleEvent( - "payload-transferred", MakePayloadTransferred("s", 1)); + auto result = handler.HandleEvent("payload-transferred", + MakePayloadTransferred("s", 1)); auto* err = std::get_if(&result); ASSERT_NE(err, nullptr); diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp index 6aa6a89a7..2d23f2105 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp @@ -112,27 +112,27 @@ void FDv2PollingSynchronizer::DoNext( boost::asio::experimental::make_parallel_group( timer_.async_wait(boost::asio::deferred), timeout_timer->async_wait(boost::asio::deferred)) - .async_wait(boost::asio::experimental::wait_for_one(), - boost::asio::bind_cancellation_slot( - cancel_signal_.slot(), - [this, deadline, selector = std::move(selector), - promise, timeout_timer]( - std::array order, - boost::system::error_code, - boost::system::error_code) mutable { - if (closed_) { - promise->set_value(FDv2SourceResult{ - FDv2SourceResult::Shutdown{}}); - return; - } - if (order[0] == 1) { - promise->set_value(FDv2SourceResult{ - FDv2SourceResult::Timeout{}}); - return; - } - DoPoll(deadline, selector, - std::move(promise)); - })); + .async_wait( + boost::asio::experimental::wait_for_one(), + boost::asio::bind_cancellation_slot( + cancel_signal_.slot(), + [this, deadline, selector = std::move(selector), + promise, + timeout_timer](std::array order, + boost::system::error_code, + boost::system::error_code) mutable { + if (closed_) { + promise->set_value(FDv2SourceResult{ + FDv2SourceResult::Shutdown{}}); + return; + } + if (order[0] == 1) { + promise->set_value(FDv2SourceResult{ + FDv2SourceResult::Timeout{}}); + return; + } + DoPoll(deadline, selector, std::move(promise)); + })); return; } } From 48989a4687a6ecbb0a0b050efbeb43acd644b230 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Sat, 18 Apr 2026 22:40:52 -0700 Subject: [PATCH 25/35] rewrite everything to use promises. --- .../include/launchdarkly/async/promise.hpp | 16 ++ .../include/launchdarkly/async/timer.hpp | 32 +++ libs/internal/tests/promise_test.cpp | 25 ++- .../source/ifdv2_initializer.hpp | 10 +- .../source/ifdv2_synchronizer.hpp | 13 +- .../data_systems/fdv2/polling_initializer.cpp | 51 ++--- .../data_systems/fdv2/polling_initializer.hpp | 22 +- .../fdv2/polling_synchronizer.cpp | 200 +++++++----------- .../fdv2/polling_synchronizer.hpp | 64 +++--- 9 files changed, 206 insertions(+), 227 deletions(-) create mode 100644 libs/internal/include/launchdarkly/async/timer.hpp diff --git a/libs/internal/include/launchdarkly/async/promise.hpp b/libs/internal/include/launchdarkly/async/promise.hpp index ffdc66b87..198e84fdb 100644 --- a/libs/internal/include/launchdarkly/async/promise.hpp +++ b/libs/internal/include/launchdarkly/async/promise.hpp @@ -437,6 +437,10 @@ class Future { std::shared_ptr> internal_; }; +// An executor that runs work immediately on the calling thread. Pass this +// to Then() when no specific thread is required for the continuation. +inline auto const kInlineExecutor = [](Continuation f) { f(); }; + // WhenAll takes a variadic list of Futures (each with potentially different // value types) and returns a Future that resolves once all // of the input futures have resolved. The result carries no value; callers @@ -528,4 +532,16 @@ Future WhenAny(Future... futures) { return result; } +// MakeFuture returns an already-resolved Future. Useful in flattening Then +// continuations where some branches produce a result immediately and others +// return a Future, requiring a uniform Future return type across all +// branches. +template +Future MakeFuture(T value) { + Promise p; + auto f = p.GetFuture(); + p.Resolve(std::move(value)); + return f; +} + } // namespace launchdarkly::async diff --git a/libs/internal/include/launchdarkly/async/timer.hpp b/libs/internal/include/launchdarkly/async/timer.hpp new file mode 100644 index 000000000..ce62889e0 --- /dev/null +++ b/libs/internal/include/launchdarkly/async/timer.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include + +#include +#include +#include +#include + +#include +#include + +namespace launchdarkly::async { + +// Returns a Future that resolves once the given duration elapses. +// The future resolves with true if the timer fired normally, or false if +// the timer was cancelled before it expired. +template +Future Delay(boost::asio::any_io_executor executor, + std::chrono::duration duration) { + auto timer = std::make_shared(executor); + timer->expires_after(duration); + Promise promise; + auto future = promise.GetFuture(); + timer->async_wait([p = std::move(promise), + timer](boost::system::error_code code) mutable { + p.Resolve(code != boost::asio::error::operation_aborted); + }); + return future; +} + +} // namespace launchdarkly::async diff --git a/libs/internal/tests/promise_test.cpp b/libs/internal/tests/promise_test.cpp index d338bcabd..8d5ecf08c 100644 --- a/libs/internal/tests/promise_test.cpp +++ b/libs/internal/tests/promise_test.cpp @@ -15,7 +15,7 @@ TEST(Promise, SimplePromise) { Future future2 = future.Then( [](int const& inner) { return static_cast(inner * 2.0); }, - [](Continuation f) { f(); }); + kInlineExecutor); promise.Resolve(43); @@ -116,9 +116,8 @@ TEST(Promise, ContinueByReturningFuture) { Promise promise2; Future future2 = promise2.GetFuture(); - Future chained = - promise1.GetFuture().Then([future2](int const&) { return future2; }, - [](Continuation f) { f(); }); + Future chained = promise1.GetFuture().Then( + [future2](int const&) { return future2; }, kInlineExecutor); promise1.Resolve(0); promise2.Resolve(42); @@ -134,8 +133,8 @@ TEST(Promise, ResolvedBeforeContinuation) { promise.Resolve(21); - Future future2 = future.Then([](int const& val) { return val * 2; }, - [](Continuation f) { f(); }); + Future future2 = + future.Then([](int const& val) { return val * 2; }, kInlineExecutor); auto result = future2.WaitForResult(std::chrono::seconds(5)); ASSERT_TRUE(result.has_value()); @@ -146,8 +145,8 @@ TEST(Promise, ResolvedAfterContinuation) { Promise promise; Future future = promise.GetFuture(); - Future future2 = future.Then([](int const& val) { return val * 2; }, - [](Continuation f) { f(); }); + Future future2 = + future.Then([](int const& val) { return val * 2; }, kInlineExecutor); promise.Resolve(21); @@ -174,7 +173,7 @@ TEST(Promise, CopyOnlyCallback) { CopyOnlyInt multiplier{2}; Future future2 = future.Then( [multiplier](int const& val) { return val * multiplier.value; }, - [](Continuation f) { f(); }); + kInlineExecutor); promise.Resolve(21); @@ -193,7 +192,7 @@ TEST(Promise, MoveOnlyCallback) { Future future2 = future.Then([captured = std::move(captured)]( int const& val) { return val * *captured; }, - [](Continuation f) { f(); }); + kInlineExecutor); promise.Resolve(21); @@ -231,7 +230,7 @@ TEST(Promise, CallbackMovedWhenPossible) { future.Then([multiplier = std::move(multiplier)]( int const& val) mutable { return val * multiplier.value; }, - [](Continuation f) { f(); }); + kInlineExecutor); EXPECT_EQ(copies, 0); @@ -251,7 +250,7 @@ TEST(Promise, MonostateVoidLike) { ran = true; return std::monostate{}; }, - [](Continuation f) { f(); }); + kInlineExecutor); promise.Resolve(std::monostate{}); @@ -407,7 +406,7 @@ TEST(WhenAny, MixedTypesFirstWins) { return f1.GetResult().value(); } }, - [](Continuation f) { f(); }); + kInlineExecutor); p1.Resolve("hello"); p0.Resolve(99); diff --git a/libs/server-sdk/src/data_interfaces/source/ifdv2_initializer.hpp b/libs/server-sdk/src/data_interfaces/source/ifdv2_initializer.hpp index 5c2608cd1..125250b1d 100644 --- a/libs/server-sdk/src/data_interfaces/source/ifdv2_initializer.hpp +++ b/libs/server-sdk/src/data_interfaces/source/ifdv2_initializer.hpp @@ -2,6 +2,8 @@ #include "fdv2_source_result.hpp" +#include + #include namespace launchdarkly::server_side::data_interfaces { @@ -14,13 +16,13 @@ namespace launchdarkly::server_side::data_interfaces { class IFDv2Initializer { public: /** - * Run the initializer to completion. Blocks until a result is available. - * Called at most once per instance. + * Returns a Future that resolves with the result once the initializer + * completes. Called at most once per instance. * * Close() may be called from another thread to unblock Run(), in which - * case Run() returns FDv2SourceResult::Shutdown. + * case the future resolves with FDv2SourceResult::Shutdown. */ - virtual FDv2SourceResult Run() = 0; + virtual async::Future Run() = 0; /** * Unblocks any in-progress Run() call, causing it to return diff --git a/libs/server-sdk/src/data_interfaces/source/ifdv2_synchronizer.hpp b/libs/server-sdk/src/data_interfaces/source/ifdv2_synchronizer.hpp index a56bacee3..96bfc54c0 100644 --- a/libs/server-sdk/src/data_interfaces/source/ifdv2_synchronizer.hpp +++ b/libs/server-sdk/src/data_interfaces/source/ifdv2_synchronizer.hpp @@ -2,6 +2,7 @@ #include "fdv2_source_result.hpp" +#include #include #include @@ -19,24 +20,26 @@ namespace launchdarkly::server_side::data_interfaces { class IFDv2Synchronizer { public: /** - * Block until the next result is available or the timeout expires. + * Returns a Future that resolves with the next result once it is available + * or the timeout expires. * * On the first call, the synchronizer starts its underlying connection. * Subsequent calls continue reading from the same connection. * - * If the timeout expires before a result arrives, returns + * If the timeout expires before a result arrives, the future resolves with * FDv2SourceResult::Timeout. The orchestrator uses this to evaluate * fallback conditions. * * Close() may be called from another thread to unblock Next(), in which - * case Next() returns FDv2SourceResult::Shutdown. + * case the future resolves with FDv2SourceResult::Shutdown. * * @param timeout Maximum time to wait for the next result. * @param selector The selector to send with the request, reflecting any * changesets applied since the previous call. */ - virtual FDv2SourceResult Next(std::chrono::milliseconds timeout, - data_model::Selector selector) = 0; + virtual async::Future Next( + std::chrono::milliseconds timeout, + data_model::Selector selector) = 0; /** * Unblocks any in-progress Next() call, causing it to return diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp index f9b2a1d44..b790b6acb 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp @@ -3,8 +3,6 @@ #include -#include - namespace launchdarkly::server_side::data_systems { static char const* const kIdentity = "FDv2 polling initializer"; @@ -25,44 +23,41 @@ FDv2PollingInitializer::FDv2PollingInitializer( filter_key)), requester_(executor, http_properties.Tls()) {} -FDv2SourceResult FDv2PollingInitializer::Run() { +async::Future FDv2PollingInitializer::Run() { if (!request_.Valid()) { LD_LOG(logger_, LogLevel::kError) << kIdentity << ": invalid polling endpoint URL"; using ErrorInfo = FDv2SourceResult::ErrorInfo; - return FDv2SourceResult{FDv2SourceResult::TerminalError{ - ErrorInfo{ErrorInfo::ErrorKind::kUnknown, 0, - "invalid polling endpoint URL", - std::chrono::system_clock::now()}, - false}}; + return async::MakeFuture( + FDv2SourceResult{FDv2SourceResult::TerminalError{ + ErrorInfo{ErrorInfo::ErrorKind::kUnknown, 0, + "invalid polling endpoint URL", + std::chrono::system_clock::now()}, + false}}); } - auto shared_result = std::make_shared>(); - + // Promisify the callback-based HTTP request. + auto http_promise = std::make_shared>(); + auto http_future = http_promise->GetFuture(); requester_.Request(request_, - [this, shared_result](network::HttpResult res) { - std::lock_guard guard(mutex_); - *shared_result = std::move(res); - cv_.notify_one(); + [hp = http_promise](network::HttpResult res) mutable { + hp->Resolve(std::move(res)); }); - std::unique_lock lock(mutex_); - cv_.wait(lock, [&] { return shared_result->has_value() || closed_; }); - - if (closed_) { - return FDv2SourceResult{FDv2SourceResult::Shutdown{}}; - } - - auto http_result = std::move(**shared_result); - lock.unlock(); - - return HandlePollResult(http_result); + // Race: HTTP result (0) vs close (1). + return async::WhenAny(http_future, close_promise_.GetFuture()) + .Then( + [this, http_future](std::size_t const& idx) -> FDv2SourceResult { + if (idx == 1) { + return FDv2SourceResult{FDv2SourceResult::Shutdown{}}; + } + return HandlePollResult(*http_future.GetResult()); + }, + async::kInlineExecutor); } void FDv2PollingInitializer::Close() { - std::lock_guard lock(mutex_); - closed_ = true; - cv_.notify_one(); + close_promise_.Resolve(std::monostate{}); } std::string const& FDv2PollingInitializer::Identity() const { diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp index 300ac1c3d..dc54252c8 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp @@ -2,6 +2,7 @@ #include "../../data_interfaces/source/ifdv2_initializer.hpp" +#include #include #include #include @@ -10,8 +11,6 @@ #include -#include -#include #include #include @@ -20,16 +19,16 @@ namespace launchdarkly::server_side::data_systems { /** * FDv2 polling initializer. Makes a single HTTP GET to the FDv2 polling * endpoint, parses the response via the FDv2 protocol state machine, and - * returns the result. Implements IFDv2Initializer (blocking, one-shot). + * returns the result. Implements IFDv2Initializer (async, one-shot). * * Threading model: - * Run() is called once from the orchestrator thread. It posts the HTTP - * request to the ASIO executor and blocks on a condition variable until - * the response arrives or Close() is called. + * Run() is called once from the orchestrator thread. It fires the HTTP + * request and returns a Future that resolves when the response arrives + * or Close() is called. * Close() may be called from any thread, concurrently with Run(). * Destroying this object is not safe until the ASIO thread has been * joined, because the HTTP response callback posted to the executor - * captures a pointer to this object's mutex and condition variable. + * captures member variables. */ class FDv2PollingInitializer final : public data_interfaces::IFDv2Initializer { public: @@ -40,7 +39,7 @@ class FDv2PollingInitializer final : public data_interfaces::IFDv2Initializer { data_model::Selector selector, std::optional filter_key); - data_interfaces::FDv2SourceResult Run() override; + async::Future Run() override; void Close() override; @@ -59,11 +58,8 @@ class FDv2PollingInitializer final : public data_interfaces::IFDv2Initializer { network::AsioRequester requester_; FDv2ProtocolHandler protocol_handler_; - // Cross-thread synchronization. Run() waits on cv_ for either a - // response from the ASIO thread or a Close() from any thread. - std::mutex mutex_; - std::condition_variable cv_; - bool closed_ = false; // guarded by mutex_ + // Resolved when Close() is called, cancelling any outstanding Run(). + async::Promise close_promise_; }; } // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp index 2d23f2105..45858b0bc 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp @@ -1,16 +1,10 @@ #include "polling_synchronizer.hpp" #include "fdv2_polling_impl.hpp" +#include #include -#include -#include -#include -#include - #include -#include -#include namespace launchdarkly::server_side::data_systems { @@ -29,12 +23,12 @@ FDv2PollingSynchronizer::FDv2PollingSynchronizer( std::optional filter_key, std::chrono::seconds poll_interval) : logger_(logger), + executor_(executor), requester_(executor, http_properties.Tls()), endpoints_(endpoints), http_properties_(http_properties), filter_key_(std::move(filter_key)), - poll_interval_(std::max(poll_interval, kMinPollInterval)), - timer_(executor) { + poll_interval_(std::max(poll_interval, kMinPollInterval)) { if (poll_interval < kMinPollInterval) { LD_LOG(logger_, LogLevel::kWarn) << kIdentity << ": polling interval too frequent, defaulting to " @@ -48,145 +42,100 @@ network::HttpRequest FDv2PollingSynchronizer::MakeRequest( filter_key_); } -FDv2SourceResult FDv2PollingSynchronizer::Next( +async::Future FDv2PollingSynchronizer::Next( std::chrono::milliseconds timeout, data_model::Selector selector) { if (closed_) { - return FDv2SourceResult{FDv2SourceResult::Shutdown{}}; + return async::MakeFuture( + FDv2SourceResult{FDv2SourceResult::Shutdown{}}); } - auto promise = std::make_shared>(); - auto future = promise->get_future(); - - // Post the actual work to the ASIO thread so that timer_ and - // cancel_signal_ are only ever accessed from the executor. Calling their - // methods directly here (on the orchestrator thread) would be a data race, - // since ASIO I/O objects and cancellation_signal are not thread-safe. - // future.get() below blocks the orchestrator thread until DoNext/DoPoll - // sets the promise from the ASIO thread. - boost::asio::post( - timer_.get_executor(), - [this, timeout, selector = std::move(selector), promise]() mutable { - DoNext(timeout, std::move(selector), std::move(promise)); - }); - - return future.get(); -} + auto deadline = std::chrono::steady_clock::now() + timeout; -void FDv2PollingSynchronizer::DoNext( - std::chrono::milliseconds timeout, - data_model::Selector selector, - std::shared_ptr> promise) { - if (closed_) { - promise->set_value(FDv2SourceResult{FDv2SourceResult::Shutdown{}}); - return; + if (!started_) { + return DoPoll(deadline, selector); } - auto deadline = std::chrono::steady_clock::now() + timeout; + auto elapsed = std::chrono::steady_clock::now() - last_poll_start_; + auto delay = poll_interval_ - elapsed; - if (started_) { - auto elapsed = std::chrono::duration_cast( - std::chrono::steady_clock::now() - last_poll_start_); - auto delay = std::chrono::duration_cast( - poll_interval_) - - elapsed; - - if (delay.count() > 0) { - auto remaining = - std::chrono::duration_cast( - deadline - std::chrono::steady_clock::now()); - - // timeout_timer must outlive this function: the io_context's - // internal timer heap holds a pointer to the timer's - // implementation until the async_wait completes, and the - // destructor cancels any pending async_wait (posting - // operation_aborted), which would make the parallel_group - // immediately complete as if the timeout had fired. Capturing - // the shared_ptr in the callback lambda keeps the timer alive - // until the group completes. - auto timeout_timer = std::make_shared( - timer_.get_executor()); - timer_.expires_after(delay); - timeout_timer->expires_after(remaining); - - boost::asio::experimental::make_parallel_group( - timer_.async_wait(boost::asio::deferred), - timeout_timer->async_wait(boost::asio::deferred)) - .async_wait( - boost::asio::experimental::wait_for_one(), - boost::asio::bind_cancellation_slot( - cancel_signal_.slot(), - [this, deadline, selector = std::move(selector), - promise, - timeout_timer](std::array order, - boost::system::error_code, - boost::system::error_code) mutable { - if (closed_) { - promise->set_value(FDv2SourceResult{ - FDv2SourceResult::Shutdown{}}); - return; - } - if (order[0] == 1) { - promise->set_value(FDv2SourceResult{ - FDv2SourceResult::Timeout{}}); - return; - } - DoPoll(deadline, selector, std::move(promise)); - })); - return; - } + if (delay.count() <= 0) { + return DoPoll(deadline, selector); } - DoPoll(deadline, selector, std::move(promise)); + auto remaining = deadline - std::chrono::steady_clock::now(); + + // Use the smaller of the two durations. If the timeout fires + // first (timeout_first == true), return Timeout; otherwise the + // inter-poll delay elapsed, so proceed to DoPoll. + bool timeout_first = remaining <= delay; + auto effective_delay = timeout_first ? remaining : delay; + + return async::WhenAny(close_promise_.GetFuture(), + async::Delay(executor_, effective_delay)) + .Then( + [this, deadline, selector = std::move(selector), + timeout_first](std::size_t const& idx) mutable + -> async::Future { + if (idx == 0 || closed_) { + return async::MakeFuture( + FDv2SourceResult{FDv2SourceResult::Shutdown{}}); + } + if (timeout_first) { + return async::MakeFuture( + FDv2SourceResult{FDv2SourceResult::Timeout{}}); + } + return DoPoll(deadline, selector); + }, + async::kInlineExecutor); } -void FDv2PollingSynchronizer::DoPoll( +async::Future FDv2PollingSynchronizer::DoPoll( std::chrono::time_point deadline, - data_model::Selector const& selector, - std::shared_ptr> promise) { + data_model::Selector const& selector) { if (closed_) { - promise->set_value(FDv2SourceResult{FDv2SourceResult::Shutdown{}}); - return; + return async::MakeFuture( + FDv2SourceResult{FDv2SourceResult::Shutdown{}}); } started_ = true; last_poll_start_ = std::chrono::steady_clock::now(); - protocol_handler_.Reset(); - - auto remaining = std::chrono::duration_cast( - deadline - std::chrono::steady_clock::now()); - timer_.expires_after(remaining); - - boost::asio::experimental::make_parallel_group( - requester_.Request(MakeRequest(selector), boost::asio::deferred), - timer_.async_wait(boost::asio::deferred)) - .async_wait( - boost::asio::experimental::wait_for_one(), - boost::asio::bind_cancellation_slot( - cancel_signal_.slot(), - [this, promise](std::array order, - network::HttpResult poll_result, - boost::system::error_code) mutable { - if (closed_) { - promise->set_value( - FDv2SourceResult{FDv2SourceResult::Shutdown{}}); - } else if (order[0] == 0) { - promise->set_value(HandlePollResult(poll_result)); - } else { - promise->set_value( - FDv2SourceResult{FDv2SourceResult::Timeout{}}); - } - })); + { + std::lock_guard lock(protocol_handler_mutex_); + protocol_handler_.Reset(); + } + + auto remaining = deadline - std::chrono::steady_clock::now(); + + // Promisify the callback-based HTTP request. + auto http_promise = std::make_shared>(); + auto http_future = http_promise->GetFuture(); + requester_.Request(MakeRequest(selector), + [hp = http_promise](network::HttpResult res) mutable { + hp->Resolve(std::move(res)); + }); + + // Race: close (0) vs HTTP result (1) vs timeout (2). + // The winner unblocks the orchestrator immediately; the losers complete + // in the background and their Resolve() calls are no-ops. + return async::WhenAny(close_promise_.GetFuture(), http_future, + async::Delay(executor_, remaining)) + .Then( + [this, http_future](std::size_t const& idx) -> FDv2SourceResult { + if (idx == 0 || closed_) { + return FDv2SourceResult{FDv2SourceResult::Shutdown{}}; + } + if (idx == 1) { + return HandlePollResult(*http_future.GetResult()); + } + return FDv2SourceResult{FDv2SourceResult::Timeout{}}; + }, + async::kInlineExecutor); } void FDv2PollingSynchronizer::Close() { closed_ = true; - // cancel_signal_ is not thread-safe, so emit() must run on the ASIO - // thread. post() schedules it there rather than calling it directly, - // which would race with DoNext/DoPoll accessing the signal concurrently. - boost::asio::post(timer_.get_executor(), [this] { - cancel_signal_.emit(boost::asio::cancellation_type::all); - }); + close_promise_.Resolve(std::monostate{}); } std::string const& FDv2PollingSynchronizer::Identity() const { @@ -196,6 +145,7 @@ std::string const& FDv2PollingSynchronizer::Identity() const { FDv2SourceResult FDv2PollingSynchronizer::HandlePollResult( network::HttpResult const& res) { + std::lock_guard lock(protocol_handler_mutex_); return HandleFDv2PollResponse(res, protocol_handler_, logger_, kIdentity); } diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp index 5b2ff5207..f032f7f74 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp @@ -2,19 +2,17 @@ #include "../../data_interfaces/source/ifdv2_synchronizer.hpp" +#include #include #include #include #include #include -#include -#include #include #include -#include -#include +#include #include #include @@ -22,18 +20,16 @@ namespace launchdarkly::server_side::data_systems { /** * FDv2 polling synchronizer. Repeatedly polls the FDv2 polling endpoint at - * a configurable interval. Implements IFDv2Synchronizer (blocking). + * a configurable interval. Implements IFDv2Synchronizer (async). * * The caller passes the current selector into each Next() call, allowing the * orchestrator to reflect applied changesets without any shared state. * * Threading model: - * Next() may be called from any single thread (the orchestrator thread). - * Close() may be called from any thread, concurrently with Next(). - * Next() posts work to the ASIO executor and blocks on a future; the actual - * I/O and timer operations run on the ASIO thread. Destroying this object - * is not safe until the ASIO thread has been joined, because in-flight - * callbacks posted to the executor may still reference member variables. + * Next() may be called from any thread. Close() may be called from any + * thread, concurrently with Next(). Destroying this object is not safe + * until all in-flight callbacks posted to the executor have completed, + * because they may still reference member variables. */ class FDv2PollingSynchronizer final : public data_interfaces::IFDv2Synchronizer { @@ -46,7 +42,7 @@ class FDv2PollingSynchronizer final std::optional filter_key, std::chrono::seconds poll_interval); - data_interfaces::FDv2SourceResult Next( + async::Future Next( std::chrono::milliseconds timeout, data_model::Selector selector) override; @@ -55,19 +51,11 @@ class FDv2PollingSynchronizer final [[nodiscard]] std::string const& Identity() const override; private: - // Called on the ASIO thread. Waits out any remaining inter-poll delay, - // then calls DoPoll. - void DoNext(std::chrono::milliseconds timeout, - data_model::Selector selector, - std::shared_ptr> - promise); - - // Called on the ASIO thread. Fires the HTTP request and waits for the - // response or timeout, then sets the promise. - void DoPoll(std::chrono::time_point deadline, - data_model::Selector const& selector, - std::shared_ptr> - promise); + // Fires the HTTP request and races it against the timeout and close + // signal. Returns a Future that resolves with the result. + async::Future DoPoll( + std::chrono::time_point deadline, + data_model::Selector const& selector); network::HttpRequest MakeRequest( data_model::Selector const& selector) const; @@ -81,29 +69,27 @@ class FDv2PollingSynchronizer final std::optional const filter_key_; std::chrono::seconds const poll_interval_; - // Mutable state accessed only from the ASIO thread (via DoNext/DoPoll). + // Used to construct Delay() timers. any_io_executor is a value type safe + // to use from any thread. + boost::asio::any_io_executor executor_; + + // Accessed only from the orchestrator thread (via DoNext/DoPoll). network::AsioRequester requester_; - FDv2ProtocolHandler protocol_handler_; bool started_ = false; std::chrono::time_point last_poll_start_; - // Thread-safety-sensitive members. See threading model note in the class - // doc above. - - // Accessed only from the ASIO thread. ASIO I/O objects are not thread-safe; - // all operations on timer_ must run on the executor. - boost::asio::steady_timer timer_; + // Protects protocol_handler_, which is reset in DoPoll and read in the + // Then continuation when an HTTP response arrives. + std::mutex protocol_handler_mutex_; + FDv2ProtocolHandler protocol_handler_; // Written by Close() from any thread; read by DoNext/DoPoll on the ASIO // thread. Must be atomic to avoid a data race. std::atomic closed_{false}; - // Accessed only from the ASIO thread. boost::asio::cancellation_signal is - // not thread-safe: slot() (which registers a handler) and emit() (which - // fires it) must not be called concurrently from different threads. Both - // happen on the ASIO thread here: DoNext/DoPoll call slot() when initiating - // a parallel_group, and Close() posts emit() to the executor. - boost::asio::cancellation_signal cancel_signal_; + // Resolved when Close() is called, cancelling any outstanding calls to + // Next(). + async::Promise close_promise_; }; } // namespace launchdarkly::server_side::data_systems From 45befda4353eff976e7b5c837a685a9f4cc58200 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Sun, 19 Apr 2026 22:45:12 -0700 Subject: [PATCH 26/35] fix: make it safe to delete the synchronizer at any time --- .../launchdarkly/network/asio_requester.hpp | 4 +- .../data_systems/fdv2/polling_initializer.cpp | 21 ++- .../data_systems/fdv2/polling_initializer.hpp | 31 ++- .../fdv2/polling_synchronizer.cpp | 178 ++++++++++-------- .../fdv2/polling_synchronizer.hpp | 102 ++++++---- 5 files changed, 201 insertions(+), 135 deletions(-) diff --git a/libs/internal/include/launchdarkly/network/asio_requester.hpp b/libs/internal/include/launchdarkly/network/asio_requester.hpp index 580ebbdb1..cf0927417 100644 --- a/libs/internal/include/launchdarkly/network/asio_requester.hpp +++ b/libs/internal/include/launchdarkly/network/asio_requester.hpp @@ -282,7 +282,7 @@ class AsioRequester { } template - auto Request(HttpRequest request, CompletionToken&& token) { + auto Request(HttpRequest request, CompletionToken&& token) const { return boost::asio::async_initiate( [this](auto handler, HttpRequest req) { InnerRequest( @@ -307,7 +307,7 @@ class AsioRequester { void InnerRequest(boost::asio::any_io_executor exec, std::optional request, std::function callback, - unsigned char redirect_count) { + unsigned char redirect_count) const { if (redirect_count > kRedirectLimit) { boost::asio::post(exec, [callback, request]() mutable { callback( diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp index b790b6acb..5e79fcf28 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp @@ -16,16 +16,20 @@ FDv2PollingInitializer::FDv2PollingInitializer( config::built::HttpProperties const& http_properties, data_model::Selector selector, std::optional filter_key) - : logger_(logger), - request_(MakeFDv2PollRequest(endpoints, + : request_(MakeFDv2PollRequest(endpoints, http_properties, selector, filter_key)), - requester_(executor, http_properties.Tls()) {} + requester_(executor, http_properties.Tls()), + state_(std::make_shared(logger)) {} + +FDv2PollingInitializer::~FDv2PollingInitializer() { + close_promise_.Resolve(std::monostate{}); +} async::Future FDv2PollingInitializer::Run() { if (!request_.Valid()) { - LD_LOG(logger_, LogLevel::kError) + LD_LOG(state_->logger, LogLevel::kError) << kIdentity << ": invalid polling endpoint URL"; using ErrorInfo = FDv2SourceResult::ErrorInfo; return async::MakeFuture( @@ -47,11 +51,12 @@ async::Future FDv2PollingInitializer::Run() { // Race: HTTP result (0) vs close (1). return async::WhenAny(http_future, close_promise_.GetFuture()) .Then( - [this, http_future](std::size_t const& idx) -> FDv2SourceResult { + [state = state_, + http_future](std::size_t const& idx) -> FDv2SourceResult { if (idx == 1) { return FDv2SourceResult{FDv2SourceResult::Shutdown{}}; } - return HandlePollResult(*http_future.GetResult()); + return HandlePollResult(state, *http_future.GetResult()); }, async::kInlineExecutor); } @@ -66,8 +71,10 @@ std::string const& FDv2PollingInitializer::Identity() const { } FDv2SourceResult FDv2PollingInitializer::HandlePollResult( + std::shared_ptr state, network::HttpResult const& res) { - return HandleFDv2PollResponse(res, protocol_handler_, logger_, kIdentity); + return HandleFDv2PollResponse(res, state->protocol_handler, state->logger, + kIdentity); } } // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp index dc54252c8..7844627ca 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp @@ -11,6 +11,7 @@ #include +#include #include #include @@ -26,9 +27,8 @@ namespace launchdarkly::server_side::data_systems { * request and returns a Future that resolves when the response arrives * or Close() is called. * Close() may be called from any thread, concurrently with Run(). - * Destroying this object is not safe until the ASIO thread has been - * joined, because the HTTP response callback posted to the executor - * captures member variables. + * This object may be safely destroyed once no call to Run() or Close() + * is in progress. */ class FDv2PollingInitializer final : public data_interfaces::IFDv2Initializer { public: @@ -39,6 +39,8 @@ class FDv2PollingInitializer final : public data_interfaces::IFDv2Initializer { data_model::Selector selector, std::optional filter_key); + ~FDv2PollingInitializer() override; + async::Future Run() override; void Close() override; @@ -46,20 +48,31 @@ class FDv2PollingInitializer final : public data_interfaces::IFDv2Initializer { [[nodiscard]] std::string const& Identity() const override; private: - data_interfaces::FDv2SourceResult HandlePollResult( + // State needed by async callbacks. Shared so callbacks can safely + // outlive 'this'. + struct State { + Logger logger; + FDv2ProtocolHandler protocol_handler; + + explicit State(Logger logger) : logger(std::move(logger)) {} + }; + + static data_interfaces::FDv2SourceResult HandlePollResult( + std::shared_ptr state, network::HttpResult const& res); // Immutable after construction; safe to read from any thread. - Logger const& logger_; network::HttpRequest const request_; - // Mutable state accessed only from the ASIO thread (via the - // requester_ callback). + // Accessed only synchronously from Run(). network::AsioRequester requester_; - FDv2ProtocolHandler protocol_handler_; - // Resolved when Close() is called, cancelling any outstanding Run(). + // Resolved when Close() is called (or this object is destroyed), + // cancelling any outstanding Run(). async::Promise close_promise_; + + // Shared with async callbacks. + std::shared_ptr state_; }; } // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp index 45858b0bc..a7a442db9 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp @@ -15,138 +15,162 @@ static constexpr std::chrono::seconds kMinPollInterval{30}; using data_interfaces::FDv2SourceResult; -FDv2PollingSynchronizer::FDv2PollingSynchronizer( +FDv2PollingSynchronizer::State::State( + Logger logger, boost::asio::any_io_executor const& executor, - Logger const& logger, + std::chrono::seconds poll_interval, config::built::ServiceEndpoints const& endpoints, config::built::HttpProperties const& http_properties, std::optional filter_key, - std::chrono::seconds poll_interval) + async::Future closed) : logger_(logger), - executor_(executor), requester_(executor, http_properties.Tls()), + executor_(executor), + poll_interval_(std::max(poll_interval, kMinPollInterval)), endpoints_(endpoints), http_properties_(http_properties), - filter_key_(std::move(filter_key)), - poll_interval_(std::max(poll_interval, kMinPollInterval)) { + filter_key_(std::move(filter_key)) {} + +FDv2PollingSynchronizer::FDv2PollingSynchronizer( + boost::asio::any_io_executor const& executor, + Logger const& logger, + config::built::ServiceEndpoints const& endpoints, + config::built::HttpProperties const& http_properties, + std::optional filter_key, + std::chrono::seconds poll_interval) + : state_(std::make_shared(logger, + executor, + poll_interval, + endpoints, + http_properties, + std::move(filter_key), + close_promise_.GetFuture())) { if (poll_interval < kMinPollInterval) { - LD_LOG(logger_, LogLevel::kWarn) + LD_LOG(logger, LogLevel::kWarn) << kIdentity << ": polling interval too frequent, defaulting to " << kMinPollInterval.count() << " seconds"; } } -network::HttpRequest FDv2PollingSynchronizer::MakeRequest( - data_model::Selector const& selector) const { - return MakeFDv2PollRequest(endpoints_, http_properties_, selector, - filter_key_); -} - async::Future FDv2PollingSynchronizer::Next( std::chrono::milliseconds timeout, data_model::Selector selector) { - if (closed_) { - return async::MakeFuture( - FDv2SourceResult{FDv2SourceResult::Shutdown{}}); - } + return DoNext(state_, close_promise_.GetFuture(), timeout, selector); +} - auto deadline = std::chrono::steady_clock::now() + timeout; +void FDv2PollingSynchronizer::Close() { + close_promise_.Resolve(std::monostate{}); +} - if (!started_) { - return DoPoll(deadline, selector); - } +std::string const& FDv2PollingSynchronizer::Identity() const { + static std::string const identity = kIdentity; + return identity; +} - auto elapsed = std::chrono::steady_clock::now() - last_poll_start_; - auto delay = poll_interval_ - elapsed; +/* static */ async::Future FDv2PollingSynchronizer::DoNext( + std::shared_ptr state, + async::Future closed, + std::chrono::milliseconds timeout, + data_model::Selector const& selector) { + auto now = std::chrono::steady_clock::now(); + auto timeout_deadline = now + timeout; + auto timeout_future = state->Delay(timeout); - if (delay.count() <= 0) { - return DoPoll(deadline, selector); + if (closed.IsFinished()) { + return async::MakeFuture( + FDv2SourceResult{FDv2SourceResult::Shutdown{}}); } - auto remaining = deadline - std::chrono::steady_clock::now(); - - // Use the smaller of the two durations. If the timeout fires - // first (timeout_first == true), return Timeout; otherwise the - // inter-poll delay elapsed, so proceed to DoPoll. - bool timeout_first = remaining <= delay; - auto effective_delay = timeout_first ? remaining : delay; + // Figure out how much to delay before starting. + auto delay_future = state->CreatePollDelayFuture(); - return async::WhenAny(close_promise_.GetFuture(), - async::Delay(executor_, effective_delay)) + return async::WhenAny(closed, timeout_future, delay_future) .Then( - [this, deadline, selector = std::move(selector), - timeout_first](std::size_t const& idx) mutable + [state, closed, timeout_deadline, + selector = std::move(selector)](std::size_t const& idx) mutable -> async::Future { - if (idx == 0 || closed_) { + if (idx == 0) { return async::MakeFuture( FDv2SourceResult{FDv2SourceResult::Shutdown{}}); } - if (timeout_first) { + if (idx == 1) { return async::MakeFuture( FDv2SourceResult{FDv2SourceResult::Timeout{}}); } - return DoPoll(deadline, selector); + return DoPoll(state, closed, timeout_deadline, selector); }, async::kInlineExecutor); } -async::Future FDv2PollingSynchronizer::DoPoll( - std::chrono::time_point deadline, +async::Future FDv2PollingSynchronizer::State::Request( + data_model::Selector const& selector) const { + auto request = MakeFDv2PollRequest(endpoints_, http_properties_, selector, + filter_key_); + + // Promise must be in a shared_ptr because AsioRequester requires callbacks + // to be copy-constructible (stored in std::function). + auto promise = std::make_shared>(); + auto future = promise->GetFuture(); + requester_.Request(request, [promise](network::HttpResult res) mutable { + promise->Resolve(std::move(res)); + }); + return future; +} + +/* static */ async::Future FDv2PollingSynchronizer::DoPoll( + std::shared_ptr state, + async::Future closed, + std::chrono::time_point timeout_deadline, data_model::Selector const& selector) { - if (closed_) { + if (closed.IsFinished()) { return async::MakeFuture( FDv2SourceResult{FDv2SourceResult::Shutdown{}}); } - started_ = true; - last_poll_start_ = std::chrono::steady_clock::now(); - { - std::lock_guard lock(protocol_handler_mutex_); - protocol_handler_.Reset(); - } + state->RecordPollStarted(); - auto remaining = deadline - std::chrono::steady_clock::now(); - - // Promisify the callback-based HTTP request. - auto http_promise = std::make_shared>(); - auto http_future = http_promise->GetFuture(); - requester_.Request(MakeRequest(selector), - [hp = http_promise](network::HttpResult res) mutable { - hp->Resolve(std::move(res)); - }); - - // Race: close (0) vs HTTP result (1) vs timeout (2). - // The winner unblocks the orchestrator immediately; the losers complete - // in the background and their Resolve() calls are no-ops. - return async::WhenAny(close_promise_.GetFuture(), http_future, - async::Delay(executor_, remaining)) + auto now = std::chrono::steady_clock::now(); + auto timeout_future = state->Delay(timeout_deadline - now); + auto http_future = state->Request(selector); + + return async::WhenAny(closed, timeout_future, http_future) .Then( - [this, http_future](std::size_t const& idx) -> FDv2SourceResult { - if (idx == 0 || closed_) { + [state, http_future](std::size_t const& idx) -> FDv2SourceResult { + if (idx == 0) { return FDv2SourceResult{FDv2SourceResult::Shutdown{}}; } if (idx == 1) { - return HandlePollResult(*http_future.GetResult()); + return FDv2SourceResult{FDv2SourceResult::Timeout{}}; } - return FDv2SourceResult{FDv2SourceResult::Timeout{}}; + return state->HandlePollResult(*http_future.GetResult()); }, async::kInlineExecutor); } -void FDv2PollingSynchronizer::Close() { - closed_ = true; - close_promise_.Resolve(std::monostate{}); +FDv2SourceResult FDv2PollingSynchronizer::State::HandlePollResult( + network::HttpResult const& res) { + std::lock_guard lock(mutex_); + FDv2ProtocolHandler protocol_handler; + return HandleFDv2PollResponse(res, protocol_handler, logger_, kIdentity); } -std::string const& FDv2PollingSynchronizer::Identity() const { - static std::string const identity = kIdentity; - return identity; +async::Future FDv2PollingSynchronizer::State::CreatePollDelayFuture() { + std::lock_guard lock(mutex_); + if (!started_) { + return async::MakeFuture(true); + } + auto now = std::chrono::steady_clock::now(); + auto elapsed = now - last_poll_start_; + if (elapsed >= poll_interval_) { + return async::MakeFuture(true); + } + return Delay(poll_interval_ - elapsed); } -FDv2SourceResult FDv2PollingSynchronizer::HandlePollResult( - network::HttpResult const& res) { - std::lock_guard lock(protocol_handler_mutex_); - return HandleFDv2PollResponse(res, protocol_handler_, logger_, kIdentity); +void FDv2PollingSynchronizer::State::RecordPollStarted() { + std::lock_guard lock(mutex_); + started_ = true; + last_poll_start_ = std::chrono::steady_clock::now(); } } // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp index f032f7f74..9df212dfd 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp @@ -20,16 +20,14 @@ namespace launchdarkly::server_side::data_systems { /** * FDv2 polling synchronizer. Repeatedly polls the FDv2 polling endpoint at - * a configurable interval. Implements IFDv2Synchronizer (async). + * a configurable interval. * * The caller passes the current selector into each Next() call, allowing the * orchestrator to reflect applied changesets without any shared state. * * Threading model: * Next() may be called from any thread. Close() may be called from any - * thread, concurrently with Next(). Destroying this object is not safe - * until all in-flight callbacks posted to the executor have completed, - * because they may still reference member variables. + * thread, concurrently with Next(). */ class FDv2PollingSynchronizer final : public data_interfaces::IFDv2Synchronizer { @@ -51,45 +49,69 @@ class FDv2PollingSynchronizer final [[nodiscard]] std::string const& Identity() const override; private: - // Fires the HTTP request and races it against the timeout and close - // signal. Returns a Future that resolves with the result. - async::Future DoPoll( - std::chrono::time_point deadline, + // Any state that may be accessed by async callbacks needs to be inside this + // class, managed by a shared_ptr. All mutable members are guarded by the + // mutex. + class State { + public: + State(Logger logger, + boost::asio::any_io_executor const& executor, + std::chrono::seconds poll_interval, + config::built::ServiceEndpoints const& endpoints, + config::built::HttpProperties const& http_properties, + std::optional filter_key, + async::Future closed); + + async::Future Request( + data_model::Selector const& selector) const; + + data_interfaces::FDv2SourceResult HandlePollResult( + network::HttpResult const& res); + + async::Future CreatePollDelayFuture(); + void RecordPollStarted(); + + template + async::Future Delay(std::chrono::duration duration) { + return async::Delay(executor_, duration); + } + + private: + // TODO: Is the logger thread-safe? + Logger logger_; + + // Immutable state + std::chrono::seconds const poll_interval_; + config::built::ServiceEndpoints const endpoints_; + config::built::HttpProperties const http_properties_; + std::optional const filter_key_; + network::AsioRequester const requester_; + + // Used to construct Delay() timers. This is a thread-safe value type. + boost::asio::any_io_executor executor_; + + // Mutable state, guarded by mutex_. + std::mutex mutex_; + bool started_ = false; + std::chrono::time_point last_poll_start_; + }; + + static async::Future DoNext( + std::shared_ptr state, + async::Future closed, + std::chrono::milliseconds timeout, + data_model::Selector const& selector); + + static async::Future DoPoll( + std::shared_ptr state, + async::Future closed, + std::chrono::time_point timeout_deadline, data_model::Selector const& selector); - network::HttpRequest MakeRequest( - data_model::Selector const& selector) const; - data_interfaces::FDv2SourceResult HandlePollResult( - network::HttpResult const& res); - - // Immutable after construction; safe to read from any thread. - Logger const& logger_; - config::built::ServiceEndpoints const& endpoints_; - config::built::HttpProperties const& http_properties_; - std::optional const filter_key_; - std::chrono::seconds const poll_interval_; - - // Used to construct Delay() timers. any_io_executor is a value type safe - // to use from any thread. - boost::asio::any_io_executor executor_; - - // Accessed only from the orchestrator thread (via DoNext/DoPoll). - network::AsioRequester requester_; - bool started_ = false; - std::chrono::time_point last_poll_start_; - - // Protects protocol_handler_, which is reset in DoPoll and read in the - // Then continuation when an HTTP response arrives. - std::mutex protocol_handler_mutex_; - FDv2ProtocolHandler protocol_handler_; - - // Written by Close() from any thread; read by DoNext/DoPoll on the ASIO - // thread. Must be atomic to avoid a data race. - std::atomic closed_{false}; - - // Resolved when Close() is called, cancelling any outstanding calls to - // Next(). + // Resolved by Close(), cancelling any outstanding Next() calls. async::Promise close_promise_; + + std::shared_ptr state_; }; } // namespace launchdarkly::server_side::data_systems From cd0914d71278bf7eeb20351200493e93f3c1b6f2 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Sun, 19 Apr 2026 23:18:19 -0700 Subject: [PATCH 27/35] refactor: reorder and clean up code --- .../fdv2/polling_synchronizer.cpp | 123 ++++++++++-------- .../fdv2/polling_synchronizer.hpp | 40 ++++-- 2 files changed, 94 insertions(+), 69 deletions(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp index a7a442db9..6d1ed1474 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp @@ -2,6 +2,7 @@ #include "fdv2_polling_impl.hpp" #include +#include #include #include @@ -21,15 +22,61 @@ FDv2PollingSynchronizer::State::State( std::chrono::seconds poll_interval, config::built::ServiceEndpoints const& endpoints, config::built::HttpProperties const& http_properties, - std::optional filter_key, - async::Future closed) - : logger_(logger), - requester_(executor, http_properties.Tls()), - executor_(executor), + std::optional filter_key) + : logger_(std::move(logger)), poll_interval_(std::max(poll_interval, kMinPollInterval)), endpoints_(endpoints), http_properties_(http_properties), - filter_key_(std::move(filter_key)) {} + filter_key_(std::move(filter_key)), + requester_(executor, http_properties.Tls()), + executor_(executor) {} + +async::Future FDv2PollingSynchronizer::State::Request( + data_model::Selector const& selector) const { + auto request = MakeFDv2PollRequest(endpoints_, http_properties_, selector, + filter_key_); + + // Promise must be in a shared_ptr because AsioRequester requires callbacks + // to be copy-constructible (stored in std::function). + auto promise = std::make_shared>(); + auto future = promise->GetFuture(); + requester_.Request(request, [promise = std::move(promise)]( + network::HttpResult res) mutable { + promise->Resolve(std::move(res)); + }); + return future; +} + +FDv2SourceResult FDv2PollingSynchronizer::State::HandlePollResult( + network::HttpResult const& res) { + std::lock_guard lock(mutex_); + FDv2ProtocolHandler protocol_handler; + return HandleFDv2PollResponse(res, protocol_handler, logger_, kIdentity); +} + +async::Future FDv2PollingSynchronizer::State::Delay( + std::chrono::nanoseconds duration) { + return async::Delay(executor_, duration); +} + +async::Future FDv2PollingSynchronizer::State::CreatePollDelayFuture() { + std::lock_guard lock(mutex_); + if (!started_) { + return async::MakeFuture(true); + } + auto now = std::chrono::steady_clock::now(); + auto elapsed = now - last_poll_start_; + if (elapsed >= poll_interval_) { + return async::MakeFuture(true); + } + return Delay(poll_interval_ - elapsed); +} + +void FDv2PollingSynchronizer::State::RecordPollStarted() { + std::lock_guard lock(mutex_); + started_ = true; + last_poll_start_ = std::chrono::steady_clock::now(); +} FDv2PollingSynchronizer::FDv2PollingSynchronizer( boost::asio::any_io_executor const& executor, @@ -43,8 +90,7 @@ FDv2PollingSynchronizer::FDv2PollingSynchronizer( poll_interval, endpoints, http_properties, - std::move(filter_key), - close_promise_.GetFuture())) { + std::move(filter_key))) { if (poll_interval < kMinPollInterval) { LD_LOG(logger, LogLevel::kWarn) << kIdentity << ": polling interval too frequent, defaulting to " @@ -55,7 +101,8 @@ FDv2PollingSynchronizer::FDv2PollingSynchronizer( async::Future FDv2PollingSynchronizer::Next( std::chrono::milliseconds timeout, data_model::Selector selector) { - return DoNext(state_, close_promise_.GetFuture(), timeout, selector); + return DoNext(state_, close_promise_.GetFuture(), timeout, + std::move(selector)); } void FDv2PollingSynchronizer::Close() { @@ -71,7 +118,7 @@ std::string const& FDv2PollingSynchronizer::Identity() const { std::shared_ptr state, async::Future closed, std::chrono::milliseconds timeout, - data_model::Selector const& selector) { + data_model::Selector selector) { auto now = std::chrono::steady_clock::now(); auto timeout_deadline = now + timeout; auto timeout_future = state->Delay(timeout); @@ -84,9 +131,11 @@ std::string const& FDv2PollingSynchronizer::Identity() const { // Figure out how much to delay before starting. auto delay_future = state->CreatePollDelayFuture(); - return async::WhenAny(closed, timeout_future, delay_future) + return async::WhenAny(closed, std::move(timeout_future), + std::move(delay_future)) .Then( - [state, closed, timeout_deadline, + [state = std::move(state), closed = std::move(closed), + timeout_deadline, selector = std::move(selector)](std::size_t const& idx) mutable -> async::Future { if (idx == 0) { @@ -97,26 +146,12 @@ std::string const& FDv2PollingSynchronizer::Identity() const { return async::MakeFuture( FDv2SourceResult{FDv2SourceResult::Timeout{}}); } - return DoPoll(state, closed, timeout_deadline, selector); + return DoPoll(std::move(state), std::move(closed), + timeout_deadline, selector); }, async::kInlineExecutor); } -async::Future FDv2PollingSynchronizer::State::Request( - data_model::Selector const& selector) const { - auto request = MakeFDv2PollRequest(endpoints_, http_properties_, selector, - filter_key_); - - // Promise must be in a shared_ptr because AsioRequester requires callbacks - // to be copy-constructible (stored in std::function). - auto promise = std::make_shared>(); - auto future = promise->GetFuture(); - requester_.Request(request, [promise](network::HttpResult res) mutable { - promise->Resolve(std::move(res)); - }); - return future; -} - /* static */ async::Future FDv2PollingSynchronizer::DoPoll( std::shared_ptr state, async::Future closed, @@ -133,9 +168,11 @@ async::Future FDv2PollingSynchronizer::State::Request( auto timeout_future = state->Delay(timeout_deadline - now); auto http_future = state->Request(selector); - return async::WhenAny(closed, timeout_future, http_future) + return async::WhenAny(std::move(closed), std::move(timeout_future), + http_future) .Then( - [state, http_future](std::size_t const& idx) -> FDv2SourceResult { + [state = std::move(state), http_future = std::move(http_future)]( + std::size_t const& idx) -> FDv2SourceResult { if (idx == 0) { return FDv2SourceResult{FDv2SourceResult::Shutdown{}}; } @@ -147,30 +184,4 @@ async::Future FDv2PollingSynchronizer::State::Request( async::kInlineExecutor); } -FDv2SourceResult FDv2PollingSynchronizer::State::HandlePollResult( - network::HttpResult const& res) { - std::lock_guard lock(mutex_); - FDv2ProtocolHandler protocol_handler; - return HandleFDv2PollResponse(res, protocol_handler, logger_, kIdentity); -} - -async::Future FDv2PollingSynchronizer::State::CreatePollDelayFuture() { - std::lock_guard lock(mutex_); - if (!started_) { - return async::MakeFuture(true); - } - auto now = std::chrono::steady_clock::now(); - auto elapsed = now - last_poll_start_; - if (elapsed >= poll_interval_) { - return async::MakeFuture(true); - } - return Delay(poll_interval_ - elapsed); -} - -void FDv2PollingSynchronizer::State::RecordPollStarted() { - std::lock_guard lock(mutex_); - started_ = true; - last_poll_start_ = std::chrono::steady_clock::now(); -} - } // namespace launchdarkly::server_side::data_systems diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp index 9df212dfd..6bb0ea13c 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp @@ -2,15 +2,12 @@ #include "../../data_interfaces/source/ifdv2_synchronizer.hpp" -#include -#include #include #include #include #include -#include #include #include #include @@ -32,6 +29,10 @@ namespace launchdarkly::server_side::data_systems { class FDv2PollingSynchronizer final : public data_interfaces::IFDv2Synchronizer { public: + /** + * Constructs a synchronizer that polls at the given interval. + * If filter_key is present, only the specified payload filter is requested. + */ FDv2PollingSynchronizer( boost::asio::any_io_executor const& executor, Logger const& logger, @@ -59,22 +60,26 @@ class FDv2PollingSynchronizer final std::chrono::seconds poll_interval, config::built::ServiceEndpoints const& endpoints, config::built::HttpProperties const& http_properties, - std::optional filter_key, - async::Future closed); + std::optional filter_key); + /** Issues an async HTTP poll request and returns a Future resolving + * with the result. */ async::Future Request( data_model::Selector const& selector) const; + /** Interprets an HTTP response as a source result. */ data_interfaces::FDv2SourceResult HandlePollResult( network::HttpResult const& res); + /** Returns a Future that resolves when it is time to start the next + * poll. */ async::Future CreatePollDelayFuture(); + + /** Records that a poll has started, for interval scheduling. */ void RecordPollStarted(); - template - async::Future Delay(std::chrono::duration duration) { - return async::Delay(executor_, duration); - } + /** Returns a Future that resolves after the given duration. */ + async::Future Delay(std::chrono::nanoseconds duration); private: // TODO: Is the logger thread-safe? @@ -86,9 +91,7 @@ class FDv2PollingSynchronizer final config::built::HttpProperties const http_properties_; std::optional const filter_key_; network::AsioRequester const requester_; - - // Used to construct Delay() timers. This is a thread-safe value type. - boost::asio::any_io_executor executor_; + boost::asio::any_io_executor const executor_; // Mutable state, guarded by mutex_. std::mutex mutex_; @@ -96,12 +99,22 @@ class FDv2PollingSynchronizer final std::chrono::time_point last_poll_start_; }; + /** + * Waits for the poll interval, then delegates to DoPoll. + * Resolves with Shutdown if closed, or Timeout if the timeout expires + * first. + */ static async::Future DoNext( std::shared_ptr state, async::Future closed, std::chrono::milliseconds timeout, - data_model::Selector const& selector); + data_model::Selector selector); + /** + * Issues a single HTTP poll request and returns the result. + * Resolves with Shutdown if closed, or Timeout if timeout_deadline passes + * first. + */ static async::Future DoPoll( std::shared_ptr state, async::Future closed, @@ -111,6 +124,7 @@ class FDv2PollingSynchronizer final // Resolved by Close(), cancelling any outstanding Next() calls. async::Promise close_promise_; + // Shared with async callbacks. std::shared_ptr state_; }; From 9d08671ffebe95acda6fa34c2fa902bf83e29d60 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Sun, 19 Apr 2026 23:27:32 -0700 Subject: [PATCH 28/35] docs: fix some comments --- libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp | 1 - libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp index 6d1ed1474..c26019d26 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp @@ -49,7 +49,6 @@ async::Future FDv2PollingSynchronizer::State::Request( FDv2SourceResult FDv2PollingSynchronizer::State::HandlePollResult( network::HttpResult const& res) { - std::lock_guard lock(mutex_); FDv2ProtocolHandler protocol_handler; return HandleFDv2PollResponse(res, protocol_handler, logger_, kIdentity); } diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp index 6bb0ea13c..5d94aad68 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp @@ -82,7 +82,7 @@ class FDv2PollingSynchronizer final async::Future Delay(std::chrono::nanoseconds duration); private: - // TODO: Is the logger thread-safe? + // Logger is itself thread-safe. Logger logger_; // Immutable state From 20e3f96d926ab6cd8315c03d6ad3bcf9718cfd3c Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Sun, 19 Apr 2026 23:32:26 -0700 Subject: [PATCH 29/35] refactor: some simple optimizations --- .../src/data_systems/fdv2/polling_synchronizer.cpp | 13 ++++++------- .../src/data_systems/fdv2/polling_synchronizer.hpp | 4 ++-- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp index c26019d26..ec773cc7a 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp @@ -60,11 +60,11 @@ async::Future FDv2PollingSynchronizer::State::Delay( async::Future FDv2PollingSynchronizer::State::CreatePollDelayFuture() { std::lock_guard lock(mutex_); - if (!started_) { + if (!last_poll_start_) { return async::MakeFuture(true); } auto now = std::chrono::steady_clock::now(); - auto elapsed = now - last_poll_start_; + auto elapsed = now - *last_poll_start_; if (elapsed >= poll_interval_) { return async::MakeFuture(true); } @@ -73,7 +73,6 @@ async::Future FDv2PollingSynchronizer::State::CreatePollDelayFuture() { void FDv2PollingSynchronizer::State::RecordPollStarted() { std::lock_guard lock(mutex_); - started_ = true; last_poll_start_ = std::chrono::steady_clock::now(); } @@ -118,15 +117,15 @@ std::string const& FDv2PollingSynchronizer::Identity() const { async::Future closed, std::chrono::milliseconds timeout, data_model::Selector selector) { - auto now = std::chrono::steady_clock::now(); - auto timeout_deadline = now + timeout; - auto timeout_future = state->Delay(timeout); - if (closed.IsFinished()) { return async::MakeFuture( FDv2SourceResult{FDv2SourceResult::Shutdown{}}); } + auto now = std::chrono::steady_clock::now(); + auto timeout_deadline = now + timeout; + auto timeout_future = state->Delay(timeout); + // Figure out how much to delay before starting. auto delay_future = state->CreatePollDelayFuture(); diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp index 5d94aad68..cfa49c572 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp @@ -95,8 +95,8 @@ class FDv2PollingSynchronizer final // Mutable state, guarded by mutex_. std::mutex mutex_; - bool started_ = false; - std::chrono::time_point last_poll_start_; + std::optional> + last_poll_start_; }; /** From d37dc5ddfc08087c8718c535f20375362b23a132 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Sun, 19 Apr 2026 23:41:31 -0700 Subject: [PATCH 30/35] refactor: more cleanup --- .../data_systems/fdv2/polling_initializer.cpp | 20 ++++++++++--------- .../data_systems/fdv2/polling_initializer.hpp | 14 +++++++------ 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp index 5e79fcf28..b9147be18 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp @@ -1,6 +1,7 @@ #include "polling_initializer.hpp" #include "fdv2_polling_impl.hpp" +#include #include namespace launchdarkly::server_side::data_systems { @@ -18,8 +19,8 @@ FDv2PollingInitializer::FDv2PollingInitializer( std::optional filter_key) : request_(MakeFDv2PollRequest(endpoints, http_properties, - selector, - filter_key)), + std::move(selector), + std::move(filter_key))), requester_(executor, http_properties.Tls()), state_(std::make_shared(logger)) {} @@ -43,16 +44,16 @@ async::Future FDv2PollingInitializer::Run() { // Promisify the callback-based HTTP request. auto http_promise = std::make_shared>(); auto http_future = http_promise->GetFuture(); - requester_.Request(request_, - [hp = http_promise](network::HttpResult res) mutable { - hp->Resolve(std::move(res)); - }); + requester_.Request(request_, [hp = std::move(http_promise)]( + network::HttpResult res) mutable { + hp->Resolve(std::move(res)); + }); // Race: HTTP result (0) vs close (1). return async::WhenAny(http_future, close_promise_.GetFuture()) .Then( - [state = state_, - http_future](std::size_t const& idx) -> FDv2SourceResult { + [state = state_, http_future = std::move(http_future)]( + std::size_t const& idx) -> FDv2SourceResult { if (idx == 1) { return FDv2SourceResult{FDv2SourceResult::Shutdown{}}; } @@ -73,7 +74,8 @@ std::string const& FDv2PollingInitializer::Identity() const { FDv2SourceResult FDv2PollingInitializer::HandlePollResult( std::shared_ptr state, network::HttpResult const& res) { - return HandleFDv2PollResponse(res, state->protocol_handler, state->logger, + FDv2ProtocolHandler protocol_handler; + return HandleFDv2PollResponse(res, protocol_handler, state->logger, kIdentity); } diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp index 7844627ca..642308077 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp @@ -4,7 +4,6 @@ #include #include -#include #include #include #include @@ -32,6 +31,10 @@ namespace launchdarkly::server_side::data_systems { */ class FDv2PollingInitializer final : public data_interfaces::IFDv2Initializer { public: + /** + * Constructs an initializer for a single poll request. + * If filter_key is present, only the specified payload filter is requested. + */ FDv2PollingInitializer(boost::asio::any_io_executor const& executor, Logger const& logger, config::built::ServiceEndpoints const& endpoints, @@ -51,21 +54,20 @@ class FDv2PollingInitializer final : public data_interfaces::IFDv2Initializer { // State needed by async callbacks. Shared so callbacks can safely // outlive 'this'. struct State { + // Logger is itself thread-safe. Logger logger; - FDv2ProtocolHandler protocol_handler; explicit State(Logger logger) : logger(std::move(logger)) {} }; + /** Interprets an HTTP response as a source result. */ static data_interfaces::FDv2SourceResult HandlePollResult( std::shared_ptr state, network::HttpResult const& res); - // Immutable after construction; safe to read from any thread. + // Immutable state network::HttpRequest const request_; - - // Accessed only synchronously from Run(). - network::AsioRequester requester_; + network::AsioRequester const requester_; // Resolved when Close() is called (or this object is destroyed), // cancelling any outstanding Run(). From 3378ed1dac8e3f7f1fc41dae8a21623808e13b10 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Sun, 19 Apr 2026 23:43:30 -0700 Subject: [PATCH 31/35] fix: close the synchronizer on destruction --- .../src/data_systems/fdv2/polling_synchronizer.cpp | 4 ++++ .../src/data_systems/fdv2/polling_synchronizer.hpp | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp index ec773cc7a..970460eb7 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp @@ -96,6 +96,10 @@ FDv2PollingSynchronizer::FDv2PollingSynchronizer( } } +FDv2PollingSynchronizer::~FDv2PollingSynchronizer() { + Close(); +} + async::Future FDv2PollingSynchronizer::Next( std::chrono::milliseconds timeout, data_model::Selector selector) { diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp index cfa49c572..f74e587f8 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp @@ -41,6 +41,8 @@ class FDv2PollingSynchronizer final std::optional filter_key, std::chrono::seconds poll_interval); + ~FDv2PollingSynchronizer() override; + async::Future Next( std::chrono::milliseconds timeout, data_model::Selector selector) override; @@ -121,7 +123,8 @@ class FDv2PollingSynchronizer final std::chrono::time_point timeout_deadline, data_model::Selector const& selector); - // Resolved by Close(), cancelling any outstanding Next() calls. + // Resolved by Close() or on destruction, cancelling any outstanding Next() + // calls. async::Promise close_promise_; // Shared with async callbacks. From 66e39367fbb757cec3e29f14e386a015907dadb1 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Mon, 20 Apr 2026 00:02:15 -0700 Subject: [PATCH 32/35] fix: minor feedback fix --- libs/internal/src/fdv2_protocol_handler.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/libs/internal/src/fdv2_protocol_handler.cpp b/libs/internal/src/fdv2_protocol_handler.cpp index 458409edd..d0564e45f 100644 --- a/libs/internal/src/fdv2_protocol_handler.cpp +++ b/libs/internal/src/fdv2_protocol_handler.cpp @@ -120,6 +120,10 @@ FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleServerIntent( } auto const& intent = **result; if (intent.payloads.empty()) { + // The protocol requires exactly one payload per server-intent, so + // an empty payloads array is a spec violation. Reset to avoid + // leaking accumulated state from a prior incomplete transfer. + Reset(); return std::monostate{}; } // The protocol defines exactly one payload per intent. @@ -242,6 +246,8 @@ FDv2ProtocolHandler::Result FDv2ProtocolHandler::HandleGoodbye( auto result = boost::json::value_to, JsonError>>( data); + // Parse failures are intentionally ignored: the caller should rotate + // sources regardless of whether the reason field is readable. if (!result) { return Goodbye{std::nullopt}; } From e9afed01772880fc22bb5234b9c1f4eac8617b03 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Mon, 20 Apr 2026 00:08:28 -0700 Subject: [PATCH 33/35] docs: fix some comments --- .../src/data_systems/fdv2/polling_initializer.hpp | 8 +++----- .../src/data_systems/fdv2/polling_synchronizer.hpp | 6 ++++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp index 642308077..f6b8a8d2f 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp @@ -19,13 +19,11 @@ namespace launchdarkly::server_side::data_systems { /** * FDv2 polling initializer. Makes a single HTTP GET to the FDv2 polling * endpoint, parses the response via the FDv2 protocol state machine, and - * returns the result. Implements IFDv2Initializer (async, one-shot). + * returns the result. * * Threading model: - * Run() is called once from the orchestrator thread. It fires the HTTP - * request and returns a Future that resolves when the response arrives - * or Close() is called. - * Close() may be called from any thread, concurrently with Run(). + * Run() should only be called once at a time. + * Close() may be called concurrently with Run(). * This object may be safely destroyed once no call to Run() or Close() * is in progress. */ diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp index f74e587f8..362406c7b 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp @@ -23,8 +23,10 @@ namespace launchdarkly::server_side::data_systems { * orchestrator to reflect applied changesets without any shared state. * * Threading model: - * Next() may be called from any thread. Close() may be called from any - * thread, concurrently with Next(). + * Next() should only be called once at a time. + * Close() may be called concurrently with Next(). + * This object may be safely destroyed once no call to Next() or Close() + * is in progress. */ class FDv2PollingSynchronizer final : public data_interfaces::IFDv2Synchronizer { From 112c78ec39a17762bb294c7489a460ce6d7a8c64 Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Mon, 20 Apr 2026 14:52:45 -0700 Subject: [PATCH 34/35] refactor: use generic http requester interface --- .../launchdarkly/network/requester.hpp | 30 ++++++++++--------- libs/internal/src/network/requester.cpp | 24 ++++++++------- .../data_systems/fdv2/fdv2_polling_impl.hpp | 2 +- .../data_systems/fdv2/polling_initializer.cpp | 4 +-- .../data_systems/fdv2/polling_initializer.hpp | 4 +-- .../fdv2/polling_synchronizer.cpp | 6 ++-- .../fdv2/polling_synchronizer.hpp | 4 +-- 7 files changed, 40 insertions(+), 34 deletions(-) diff --git a/libs/internal/include/launchdarkly/network/requester.hpp b/libs/internal/include/launchdarkly/network/requester.hpp index 58579cc06..c2f23dd4b 100644 --- a/libs/internal/include/launchdarkly/network/requester.hpp +++ b/libs/internal/include/launchdarkly/network/requester.hpp @@ -1,10 +1,10 @@ #pragma once -#include "http_requester.hpp" -#include +#include #include +#include #include -#include +#include "http_requester.hpp" namespace launchdarkly::network { @@ -15,30 +15,32 @@ using TlsOptions = config::shared::built::TlsOptions; class IRequesterImpl; /** - * Requester provides HTTP request functionality using either CURL or Boost.Beast - * depending on the LD_CURL_NETWORKING compile-time flag. + * Requester provides HTTP request functionality using either CURL or + * Boost.Beast depending on the LD_CURL_NETWORKING compile-time flag. * * When LD_CURL_NETWORKING is ON: Uses CurlRequester (CURL-based implementation) - * When LD_CURL_NETWORKING is OFF: Uses AsioRequester (Boost.Beast-based implementation) + * When LD_CURL_NETWORKING is OFF: Uses AsioRequester (Boost.Beast-based + * implementation) * - * The implementation choice is made at library compile-time and hidden from users - * via the pimpl idiom to avoid ABI issues. + * The implementation choice is made at library compile-time and hidden from + * users via the pimpl idiom to avoid ABI issues. */ class Requester { -public: + public: Requester(net::any_io_executor ctx, TlsOptions const& tls_options); ~Requester(); // Move-only type Requester(Requester&&) noexcept; Requester& operator=(Requester&&) noexcept; - Requester(const Requester&) = delete; - Requester& operator=(const Requester&) = delete; + Requester(Requester const&) = delete; + Requester& operator=(Requester const&) = delete; - void Request(HttpRequest request, std::function cb); + void Request(HttpRequest request, + std::function cb) const; -private: + private: std::unique_ptr impl_; }; -} // namespace launchdarkly::network +} // namespace launchdarkly::network diff --git a/libs/internal/src/network/requester.cpp b/libs/internal/src/network/requester.cpp index d9e7bdee8..3ddad5d59 100644 --- a/libs/internal/src/network/requester.cpp +++ b/libs/internal/src/network/requester.cpp @@ -10,37 +10,40 @@ namespace launchdarkly::network { // Abstract interface for the implementation class IRequesterImpl { -public: + public: virtual ~IRequesterImpl() = default; - virtual void Request(HttpRequest request, std::function cb) = 0; + virtual void Request(HttpRequest request, + std::function cb) const = 0; }; #ifdef LD_CURL_NETWORKING // CURL-based implementation class CurlRequesterImpl : public IRequesterImpl { -public: + public: CurlRequesterImpl(net::any_io_executor ctx, TlsOptions const& tls_options) : requester_(ctx, tls_options) {} - void Request(HttpRequest request, std::function cb) override { + void Request(HttpRequest request, + std::function cb) const override { requester_.Request(std::move(request), std::move(cb)); } -private: + private: CurlRequester requester_; }; #else // Boost.Beast-based implementation class AsioRequesterImpl : public IRequesterImpl { -public: + public: AsioRequesterImpl(net::any_io_executor ctx, TlsOptions const& tls_options) : requester_(ctx, tls_options) {} - void Request(HttpRequest request, std::function cb) override { + void Request(HttpRequest request, + std::function cb) const override { requester_.Request(std::move(request), std::move(cb)); } -private: + private: AsioRequester requester_; }; #endif @@ -59,8 +62,9 @@ Requester::~Requester() = default; Requester::Requester(Requester&&) noexcept = default; Requester& Requester::operator=(Requester&&) noexcept = default; -void Requester::Request(HttpRequest request, std::function cb) { +void Requester::Request(HttpRequest request, + std::function cb) const { impl_->Request(std::move(request), std::move(cb)); } -} // namespace launchdarkly::network +} // namespace launchdarkly::network diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp index fecf79243..f58827483 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp @@ -5,7 +5,7 @@ #include #include #include -#include +#include #include #include diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp index b9147be18..ee944c3da 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp @@ -45,8 +45,8 @@ async::Future FDv2PollingInitializer::Run() { auto http_promise = std::make_shared>(); auto http_future = http_promise->GetFuture(); requester_.Request(request_, [hp = std::move(http_promise)]( - network::HttpResult res) mutable { - hp->Resolve(std::move(res)); + network::HttpResult const& res) mutable { + hp->Resolve(res); }); // Race: HTTP result (0) vs close (1). diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp index f6b8a8d2f..0fb87c4d0 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.hpp @@ -5,7 +5,7 @@ #include #include #include -#include +#include #include #include @@ -65,7 +65,7 @@ class FDv2PollingInitializer final : public data_interfaces::IFDv2Initializer { // Immutable state network::HttpRequest const request_; - network::AsioRequester const requester_; + network::Requester const requester_; // Resolved when Close() is called (or this object is destroyed), // cancelling any outstanding Run(). diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp index 970460eb7..04e3966b8 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp @@ -36,13 +36,13 @@ async::Future FDv2PollingSynchronizer::State::Request( auto request = MakeFDv2PollRequest(endpoints_, http_properties_, selector, filter_key_); - // Promise must be in a shared_ptr because AsioRequester requires callbacks + // Promise must be in a shared_ptr because Requester requires callbacks // to be copy-constructible (stored in std::function). auto promise = std::make_shared>(); auto future = promise->GetFuture(); requester_.Request(request, [promise = std::move(promise)]( - network::HttpResult res) mutable { - promise->Resolve(std::move(res)); + network::HttpResult const& res) mutable { + promise->Resolve(res); }); return future; } diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp index 362406c7b..42f20bfe1 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.hpp @@ -3,7 +3,7 @@ #include "../../data_interfaces/source/ifdv2_synchronizer.hpp" #include -#include +#include #include #include @@ -94,7 +94,7 @@ class FDv2PollingSynchronizer final config::built::ServiceEndpoints const endpoints_; config::built::HttpProperties const http_properties_; std::optional const filter_key_; - network::AsioRequester const requester_; + network::Requester const requester_; boost::asio::any_io_executor const executor_; // Mutable state, guarded by mutex_. From 4b02b670c0802f3477da78e4d9d0ef299f16ccfd Mon Sep 17 00:00:00 2001 From: Bee Klimt Date: Mon, 20 Apr 2026 16:57:42 -0700 Subject: [PATCH 35/35] refactor: break up HandleFDv2PollResponse into separate functions --- .../data_systems/fdv2/fdv2_polling_impl.cpp | 183 +++++++++--------- .../data_systems/fdv2/fdv2_polling_impl.hpp | 2 +- .../data_systems/fdv2/polling_initializer.cpp | 2 +- .../fdv2/polling_synchronizer.cpp | 2 +- 4 files changed, 97 insertions(+), 92 deletions(-) diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp index 0c1df19ec..df6fda9af 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.cpp @@ -55,9 +55,93 @@ network::HttpRequest MakeFDv2PollRequest( network::HttpRequest::BodyType{}}; } +static FDv2SourceResult ParseFDv2PollEvents( + boost::json::array const& events, + FDv2ProtocolHandler* protocol_handler) { + for (auto const& event_val : events) { + auto const* event_obj = event_val.if_object(); + if (!event_obj) { + continue; + } + + auto const* event_type_val = event_obj->if_contains("event"); + auto const* event_data_val = event_obj->if_contains("data"); + if (!event_type_val || !event_data_val) { + continue; + } + + auto const* event_type_str = event_type_val->if_string(); + if (!event_type_str) { + continue; + } + + auto result = protocol_handler->HandleEvent( + std::string_view{event_type_str->data(), event_type_str->size()}, + *event_data_val); + + if (auto* changeset = std::get_if(&result)) { + return FDv2SourceResult{ + FDv2SourceResult::ChangeSet{std::move(*changeset), false}}; + } + if (auto* goodbye = std::get_if(&result)) { + return FDv2SourceResult{ + FDv2SourceResult::Goodbye{goodbye->reason, false}}; + } + if (auto* error = std::get_if(&result)) { + if (error->kind == FDv2ProtocolHandler::Error::Kind::kServerError) { + auto const& id = error->server_error.value().id; + std::string msg = + "An issue was encountered receiving updates for " + "payload '" + + id.value_or("") + "' with reason: '" + error->message + + "'. Automatic retry will occur."; + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kErrorResponse, 0, std::move(msg)), + false}}; + } + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, error->message), false}}; + } + } + + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorIncompletePayload), false}}; +} + +static FDv2SourceResult ParseFDv2PollResponse( + std::string const& body, + FDv2ProtocolHandler* protocol_handler) { + boost::system::error_code ec; + auto parsed = boost::json::parse(body, ec); + if (ec) { + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody), false}}; + } + + auto const* obj = parsed.if_object(); + if (!obj) { + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody), false}}; + } + + auto const* events_val = obj->if_contains("events"); + if (!events_val) { + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), false}}; + } + + auto const* events_arr = events_val->if_array(); + if (!events_arr) { + return FDv2SourceResult{FDv2SourceResult::Interrupted{ + MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), false}}; + } + + return ParseFDv2PollEvents(*events_arr, protocol_handler); +} + data_interfaces::FDv2SourceResult HandleFDv2PollResponse( network::HttpResult const& res, - FDv2ProtocolHandler& protocol_handler, + FDv2ProtocolHandler* protocol_handler, Logger const& logger, std::string_view identity) { if (res.IsError()) { @@ -85,97 +169,18 @@ data_interfaces::FDv2SourceResult HandleFDv2PollResponse( false}}; } - boost::system::error_code ec; - auto parsed = boost::json::parse(*body, ec); - if (ec) { - LD_LOG(logger, LogLevel::kError) << kErrorParsingBody; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody), - false}}; - } - - auto const* obj = parsed.if_object(); - if (!obj) { - LD_LOG(logger, LogLevel::kError) << kErrorParsingBody; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorParsingBody), - false}}; - } - - auto const* events_val = obj->if_contains("events"); - if (!events_val) { - LD_LOG(logger, LogLevel::kError) << kErrorMissingEvents; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), - false}}; - } - - auto const* events_arr = events_val->if_array(); - if (!events_arr) { - LD_LOG(logger, LogLevel::kError) << kErrorMissingEvents; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorMissingEvents), - false}}; - } - - for (auto const& event_val : *events_arr) { - auto const* event_obj = event_val.if_object(); - if (!event_obj) { - continue; - } - - auto const* event_type_val = event_obj->if_contains("event"); - auto const* event_data_val = event_obj->if_contains("data"); - if (!event_type_val || !event_data_val) { - continue; - } - - auto const* event_type_str = event_type_val->if_string(); - if (!event_type_str) { - continue; - } - - auto result = protocol_handler.HandleEvent( - std::string_view{event_type_str->data(), - event_type_str->size()}, - *event_data_val); - - if (auto* changeset = - std::get_if(&result)) { - return FDv2SourceResult{ - FDv2SourceResult::ChangeSet{std::move(*changeset), false}}; - } - if (auto* goodbye = std::get_if(&result)) { - return FDv2SourceResult{ - FDv2SourceResult::Goodbye{goodbye->reason, false}}; - } - if (auto* error = - std::get_if(&result)) { - if (error->kind == - FDv2ProtocolHandler::Error::Kind::kServerError) { - auto const& id = error->server_error.value().id; - std::string msg = - "An issue was encountered receiving updates for " - "payload '" + - id.value_or("") + "' with reason: '" + error->message + - "'. Automatic retry will occur."; - LD_LOG(logger, LogLevel::kInfo) << identity << ": " << msg; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kErrorResponse, 0, std::move(msg)), - false}}; - } + auto result = ParseFDv2PollResponse(*body, protocol_handler); + if (auto* interrupted = + std::get_if(&result.value)) { + if (interrupted->error.Kind() == ErrorKind::kErrorResponse) { + LD_LOG(logger, LogLevel::kInfo) + << identity << ": " << interrupted->error.Message(); + } else { LD_LOG(logger, LogLevel::kError) - << identity << ": " << error->message; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, error->message), - false}}; + << identity << ": " << interrupted->error.Message(); } } - - LD_LOG(logger, LogLevel::kError) << kErrorIncompletePayload; - return FDv2SourceResult{FDv2SourceResult::Interrupted{ - MakeError(ErrorKind::kInvalidData, 0, kErrorIncompletePayload), - false}}; + return result; } if (network::IsRecoverableStatus(res.Status())) { diff --git a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp index f58827483..086d3c8db 100644 --- a/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp +++ b/libs/server-sdk/src/data_systems/fdv2/fdv2_polling_impl.hpp @@ -26,7 +26,7 @@ network::HttpRequest MakeFDv2PollRequest( // to identify the caller (e.g. "FDv2 polling initializer"). data_interfaces::FDv2SourceResult HandleFDv2PollResponse( network::HttpResult const& res, - FDv2ProtocolHandler& protocol_handler, + FDv2ProtocolHandler* protocol_handler, Logger const& logger, std::string_view identity); diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp index ee944c3da..174edd040 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_initializer.cpp @@ -75,7 +75,7 @@ FDv2SourceResult FDv2PollingInitializer::HandlePollResult( std::shared_ptr state, network::HttpResult const& res) { FDv2ProtocolHandler protocol_handler; - return HandleFDv2PollResponse(res, protocol_handler, state->logger, + return HandleFDv2PollResponse(res, &protocol_handler, state->logger, kIdentity); } diff --git a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp index 04e3966b8..a8dabaa54 100644 --- a/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp +++ b/libs/server-sdk/src/data_systems/fdv2/polling_synchronizer.cpp @@ -50,7 +50,7 @@ async::Future FDv2PollingSynchronizer::State::Request( FDv2SourceResult FDv2PollingSynchronizer::State::HandlePollResult( network::HttpResult const& res) { FDv2ProtocolHandler protocol_handler; - return HandleFDv2PollResponse(res, protocol_handler, logger_, kIdentity); + return HandleFDv2PollResponse(res, &protocol_handler, logger_, kIdentity); } async::Future FDv2PollingSynchronizer::State::Delay(