diff --git a/libs/server-sdk/src/evaluation/evaluation_stack.cpp b/libs/server-sdk/src/evaluation/evaluation_stack.cpp index ece6a5a0f..3e5710bda 100644 --- a/libs/server-sdk/src/evaluation/evaluation_stack.cpp +++ b/libs/server-sdk/src/evaluation/evaluation_stack.cpp @@ -2,6 +2,32 @@ namespace launchdarkly::server_side::evaluation { +namespace { +// Ranks statuses by how little they can be trusted, so the least-trustworthy +// status wins when several Big Segments are queried in one evaluation +// (NOT_CONFIGURED > STORE_ERROR > STALE > HEALTHY). Note this ordering is +// independent of the enum's underlying values. +int Precedence(enum EvaluationReason::BigSegmentsStatus status) { + switch (status) { + case EvaluationReason::BigSegmentsStatus::kNone: + return 0; + case EvaluationReason::BigSegmentsStatus::kHealthy: + return 1; + case EvaluationReason::BigSegmentsStatus::kStale: + return 2; + case EvaluationReason::BigSegmentsStatus::kStoreError: + return 3; + case EvaluationReason::BigSegmentsStatus::kNotConfigured: + return 4; + } + return 0; +} +} // namespace + +EvaluationStack::EvaluationStack( + data_components::BigSegmentStoreWrapper* big_segment_store) + : big_segment_store_(big_segment_store) {} + Guard::Guard(std::unordered_set& set, std::string key) : set_(set), key_(std::move(key)) { set_.insert(key_); @@ -27,4 +53,43 @@ std::optional EvaluationStack::NoticeSegment(std::string segment_key) { return std::make_optional(segments_seen_, std::move(segment_key)); } +data_components::BigSegmentStoreWrapper* EvaluationStack::BigSegmentStore() + const { + return big_segment_store_; +} + +void EvaluationStack::RecordBigSegmentsStatus( + enum EvaluationReason::BigSegmentsStatus status) { + if (Precedence(status) > Precedence(big_segments_status_)) { + big_segments_status_ = status; + } +} + +enum EvaluationReason::BigSegmentsStatus +EvaluationStack::BigSegmentsStatus() const { + return big_segments_status_; +} + +integrations::Membership const* EvaluationStack::FindMembership( + std::string const& context_key) const { + auto const it = memberships_.find(context_key); + if (it == memberships_.end()) { + return nullptr; + } + return &it->second; +} + +void EvaluationStack::StoreMembership(std::string context_key, + integrations::Membership membership) { + memberships_.emplace(std::move(context_key), std::move(membership)); +} + +void EvaluationStack::RecordStoreError(std::string context_key) { + store_error_keys_.insert(std::move(context_key)); +} + +bool EvaluationStack::DidStoreError(std::string const& context_key) const { + return store_error_keys_.find(context_key) != store_error_keys_.end(); +} + } // namespace launchdarkly::server_side::evaluation diff --git a/libs/server-sdk/src/evaluation/evaluation_stack.hpp b/libs/server-sdk/src/evaluation/evaluation_stack.hpp index 36f768aea..7785a8d71 100644 --- a/libs/server-sdk/src/evaluation/evaluation_stack.hpp +++ b/libs/server-sdk/src/evaluation/evaluation_stack.hpp @@ -1,9 +1,17 @@ #pragma once +#include +#include + #include #include +#include #include +namespace launchdarkly::server_side::data_components { +class BigSegmentStoreWrapper; +} // namespace launchdarkly::server_side::data_components + namespace launchdarkly::server_side::evaluation { /** @@ -26,12 +34,22 @@ struct Guard { }; /** - * EvaluationStack is used to track which segments and flags have been noticed - * during evaluation in order to detect circular references. + * EvaluationStack holds the per-evaluation state for a single top-level flag + * evaluation: the prerequisite/segment chains used for circular-reference + * detection, plus the Big Segments status and membership cache that a Big + * Segment lookup populates. + * + * Not thread-safe: a fresh instance is created per top-level evaluation and is + * never shared across threads. */ class EvaluationStack { public: - EvaluationStack() = default; + /** + * @param big_segment_store Non-owning pointer to the Big Segment store + * wrapper, or nullptr if no store is configured. Must outlive the stack. + */ + explicit EvaluationStack( + data_components::BigSegmentStoreWrapper* big_segment_store = nullptr); /** * If the given prerequisite key has not been seen, marks it as seen @@ -52,9 +70,63 @@ class EvaluationStack { */ [[nodiscard]] std::optional NoticeSegment(std::string segment_key); + /** + * @return The Big Segment store wrapper, or nullptr if none is configured. + */ + [[nodiscard]] data_components::BigSegmentStoreWrapper* BigSegmentStore() + const; + + /** + * Records the status of a Big Segment lookup. If multiple lookups occur in + * one evaluation, the least-trustworthy status wins (NOT_CONFIGURED > + * STORE_ERROR > STALE > HEALTHY). + */ + void RecordBigSegmentsStatus(enum EvaluationReason::BigSegmentsStatus status); + + /** + * @return The aggregated Big Segments status, or kNone if no Big Segment + * was queried during this evaluation. + */ + [[nodiscard]] enum EvaluationReason::BigSegmentsStatus BigSegmentsStatus() + const; + + /** + * Returns the cached membership for a context key looked up earlier in this + * evaluation, or nullptr if that key has not been queried yet. + */ + [[nodiscard]] integrations::Membership const* FindMembership( + std::string const& context_key) const; + + /** + * Caches a context key's membership so later Big Segment lookups for the + * same key in this evaluation reuse it instead of re-querying the store. + */ + void StoreMembership(std::string context_key, + integrations::Membership membership); + + /** + * Records that the Big Segment store returned an error for the given + * context key during this evaluation. Subsequent Big Segment lookups for + * the same key must be treated as non-matches without re-querying. + */ + void RecordStoreError(std::string context_key); + + /** + * @return True if a Big Segment store lookup for the given context key has + * already errored during this evaluation. + */ + [[nodiscard]] bool DidStoreError(std::string const& context_key) const; + private: std::unordered_set prerequisites_seen_; std::unordered_set segments_seen_; + + data_components::BigSegmentStoreWrapper* big_segment_store_; + enum EvaluationReason::BigSegmentsStatus big_segments_status_ = + EvaluationReason::BigSegmentsStatus::kNone; + // Keyed by unhashed context key. Empty until the first Big Segment lookup. + std::unordered_map memberships_; + std::unordered_set store_error_keys_; }; } // namespace launchdarkly::server_side::evaluation diff --git a/libs/server-sdk/src/evaluation/evaluator.cpp b/libs/server-sdk/src/evaluation/evaluator.cpp index 9f11003df..36ea348c5 100644 --- a/libs/server-sdk/src/evaluation/evaluator.cpp +++ b/libs/server-sdk/src/evaluation/evaluator.cpp @@ -20,8 +20,26 @@ std::optional TargetMatchVariation( launchdarkly::Context const& context, Flag::Target const& target); -Evaluator::Evaluator(Logger& logger, data_interfaces::IStore const& source) - : logger_(logger), source_(source) {} +namespace { +EvaluationDetail WithBigSegmentsStatus(EvaluationDetail detail, + enum EvaluationReason::BigSegmentsStatus status) { + auto const& maybe_reason = detail.Reason(); + if (!maybe_reason) { + return detail; + } + EvaluationReason const& reason = *maybe_reason; + EvaluationReason updated( + reason.Kind(), reason.ErrorKind(), reason.RuleIndex(), reason.RuleId(), + reason.PrerequisiteKey(), reason.InExperiment(), status); + return EvaluationDetail(detail.Value(), detail.VariationIndex(), + std::move(updated)); +} +} // namespace + +Evaluator::Evaluator(Logger& logger, + data_interfaces::IStore const& source, + data_components::BigSegmentStoreWrapper* big_segment_store) + : logger_(logger), source_(source), big_segment_store_(big_segment_store) {} EvaluationDetail Evaluator::Evaluate( data_model::Flag const& flag, @@ -33,8 +51,13 @@ EvaluationDetail Evaluator::Evaluate( Flag const& flag, launchdarkly::Context const& context, EventScope const& event_scope) { - EvaluationStack stack; - return Evaluate(std::nullopt, flag, context, stack, event_scope); + EvaluationStack stack{big_segment_store_}; + auto detail = Evaluate(std::nullopt, flag, context, stack, event_scope); + auto status = stack.BigSegmentsStatus(); + if (status != EvaluationReason::BigSegmentsStatus::kNone) { + return WithBigSegmentsStatus(std::move(detail), status); + } + return detail; } EvaluationDetail Evaluator::Evaluate( diff --git a/libs/server-sdk/src/evaluation/evaluator.hpp b/libs/server-sdk/src/evaluation/evaluator.hpp index 313dffff2..207e93241 100644 --- a/libs/server-sdk/src/evaluation/evaluator.hpp +++ b/libs/server-sdk/src/evaluation/evaluator.hpp @@ -21,8 +21,14 @@ class Evaluator { * threads in parallel, the given logger and IStore must be thread safe. * @param logger A logger for recording errors or warnings. * @param source The flag/segment source. + * @param big_segment_store Non-owning pointer to the Big Segment store + * wrapper, or nullptr if Big Segments are not configured. If non-null it + * must outlive the Evaluator and be safe to call from multiple threads. */ - Evaluator(Logger& logger, data_interfaces::IStore const& source); + Evaluator( + Logger& logger, + data_interfaces::IStore const& source, + data_components::BigSegmentStoreWrapper* big_segment_store = nullptr); /** * Evaluates a flag for a given context. @@ -69,5 +75,6 @@ class Evaluator { Logger& logger_; data_interfaces::IStore const& source_; + data_components::BigSegmentStoreWrapper* big_segment_store_; }; } // namespace launchdarkly::server_side::evaluation diff --git a/libs/server-sdk/src/evaluation/rules.cpp b/libs/server-sdk/src/evaluation/rules.cpp index 2242e2ab7..8e37259e9 100644 --- a/libs/server-sdk/src/evaluation/rules.cpp +++ b/libs/server-sdk/src/evaluation/rules.cpp @@ -2,10 +2,90 @@ #include "bucketing.hpp" #include "operators.hpp" +#include "../data_components/big_segments/big_segment_store_wrapper.hpp" + +#include +#include +#include + namespace launchdarkly::server_side::evaluation { using namespace data_model; +namespace { + +// Maps the wrapper's internal status to the public reason status. +enum EvaluationReason::BigSegmentsStatus ToBigSegmentsStatus( + data_components::BigSegmentsStatus status) { + switch (status) { + case data_components::BigSegmentsStatus::kHealthy: + return EvaluationReason::BigSegmentsStatus::kHealthy; + case data_components::BigSegmentsStatus::kStale: + return EvaluationReason::BigSegmentsStatus::kStale; + case data_components::BigSegmentsStatus::kStoreError: + return EvaluationReason::BigSegmentsStatus::kStoreError; + case data_components::BigSegmentsStatus::kNotConfigured: + return EvaluationReason::BigSegmentsStatus::kNotConfigured; + } + return EvaluationReason::BigSegmentsStatus::kHealthy; +} + +std::string MakeBigSegmentRef(Segment const& segment) { + return segment.key + ".g" + std::to_string(*segment.generation); +} + +// Evaluates membership in an unbounded (Big) segment. Returns true/false for a +// definite match/non-match, or std::nullopt when the membership has no entry +// for this segment and evaluation should fall through to the segment's rules. +std::optional MatchBigSegment(Segment const& segment, + Context const& context, + EvaluationStack& stack) { + if (!segment.generation) { + // Without a generation the segment ref can't be formed. + stack.RecordBigSegmentsStatus( + EvaluationReason::BigSegmentsStatus::kNotConfigured); + return false; + } + + // An absent or empty unboundedContextKind defaults to "user". + ContextKind const kind = (segment.unboundedContextKind && + !segment.unboundedContextKind->t.empty()) + ? *segment.unboundedContextKind + : ContextKind{"user"}; + Value const& context_key = context.Get(kind, "key"); + if (!context_key.IsString()) { + return false; + } + std::string const& key = context_key.AsString(); + + if (stack.DidStoreError(key)) { + return false; + } + + integrations::Membership const* membership = stack.FindMembership(key); + if (!membership) { + auto* store = stack.BigSegmentStore(); + if (!store) { + stack.RecordBigSegmentsStatus( + EvaluationReason::BigSegmentsStatus::kNotConfigured); + return false; + } + auto result = store->GetMembership(key); + auto const status = ToBigSegmentsStatus(result.status); + stack.RecordBigSegmentsStatus(status); + if (status == EvaluationReason::BigSegmentsStatus::kStoreError) { + stack.RecordStoreError(key); + return false; + } + stack.StoreMembership(key, std::move(result.membership)); + membership = stack.FindMembership(key); + } + + return membership->CheckMembership(MakeBigSegmentRef(segment)); +} + +} // namespace + bool MaybeNegate(Clause const& clause, bool value) { if (clause.negate) { return !value; @@ -161,16 +241,19 @@ tl::expected Contains(Segment const& segment, } if (segment.unbounded) { - // TODO(sc209881): set big segment status to NOT_CONFIGURED. - return false; - } - - if (IsTargeted(context, segment.included, segment.includedContexts)) { - return true; - } + if (auto match = MatchBigSegment(segment, context, stack)) { + return *match; + } + // Big segments don't use the regular include/exclude target lists; a + // membership miss falls through directly to the segment's rules. + } else { + if (IsTargeted(context, segment.included, segment.includedContexts)) { + return true; + } - if (IsTargeted(context, segment.excluded, segment.excludedContexts)) { - return false; + if (IsTargeted(context, segment.excluded, segment.excludedContexts)) { + return false; + } } for (auto const& rule : segment.rules) { diff --git a/libs/server-sdk/tests/big_segment_evaluator_test.cpp b/libs/server-sdk/tests/big_segment_evaluator_test.cpp new file mode 100644 index 000000000..632bdcc6c --- /dev/null +++ b/libs/server-sdk/tests/big_segment_evaluator_test.cpp @@ -0,0 +1,349 @@ +#include + +#include "evaluation/evaluator.hpp" +#include "test_store.hpp" + +#include +#include + +#include +#include + +#include + +#include +#include +#include +#include +#include + +using namespace launchdarkly; +using namespace launchdarkly::server_side; +namespace integrations = launchdarkly::server_side::integrations; +namespace built = launchdarkly::server_side::config::built; +using data_components::BigSegmentStoreWrapper; +using namespace std::chrono_literals; + +namespace { + +// In-memory store for evaluator tests. GetMembership returns queued responses +// in order (the last one sticks once the queue is exhausted); metadata is +// fixed. The store ignores the hashed key it is passed. +class FakeBigSegmentStore : public integrations::IBigSegmentStore { + public: + GetMembershipResult GetMembership( + std::string const&) const noexcept override { + std::lock_guard lock(mutex_); + ++membership_calls_; + if (responses_.size() > 1) { + auto front = responses_.front(); + responses_.pop_front(); + return front; + } + if (!responses_.empty()) { + return responses_.front(); + } + return integrations::Membership::FromSegmentRefs({}, {}); + } + + GetMetadataResult GetMetadata() const noexcept override { + std::lock_guard lock(mutex_); + return metadata_; + } + + void PushMembership(GetMembershipResult response) { + std::lock_guard lock(mutex_); + responses_.push_back(std::move(response)); + } + + void SetMetadata(GetMetadataResult metadata) { + std::lock_guard lock(mutex_); + metadata_ = std::move(metadata); + } + + int MembershipCalls() const { + std::lock_guard lock(mutex_); + return membership_calls_; + } + + private: + mutable std::mutex mutex_; + mutable int membership_calls_ = 0; + mutable std::deque responses_; + // Defaults to fresh metadata so a successful lookup reports HEALTHY. + GetMetadataResult metadata_ = std::optional{ + integrations::StoreMetadata{std::chrono::system_clock::now()}}; +}; + +// A membership that includes a context in the given segment ref. +integrations::Membership Included(std::string const& ref) { + return integrations::Membership::FromSegmentRefs({ref}, {}); +} + +// A membership that excludes a context from the given segment ref. +integrations::Membership Excluded(std::string const& ref) { + return integrations::Membership::FromSegmentRefs({}, {ref}); +} + +std::string UnboundedSegment(std::string const& key, + std::string const& kind, + bool with_generation, + std::string const& rules) { + std::string json = R"({"key":")" + key + + R"(","unbounded":true,"unboundedContextKind":")" + kind + + R"(","included":[],"excluded":[],"rules":[)" + rules + + R"(],"salt":"salty","version":1)"; + if (with_generation) { + json += R"(,"generation":1)"; + } + json += "}"; + return json; +} + +// A flag that serves variation 0 (false) when any of its segmentMatch rules +// matches, otherwise its fallthrough variation 1 (true). One rule per segment. +std::string FlagMatchingSegments(std::vector const& segment_keys) { + std::string rules; + for (std::size_t i = 0; i < segment_keys.size(); ++i) { + if (i != 0) { + rules += ","; + } + rules += R"({"id":"rule-)" + std::to_string(i) + + R"(","clauses":[{"op":"segmentMatch","values":[")" + + segment_keys[i] + R"("]}],"variation":0,"trackEvents":false})"; + } + return R"({"key":"flag","version":42,"on":true,"targets":[],"rules":[)" + + rules + + R"(],"prerequisites":[],"fallthrough":{"variation":1},)" + R"("offVariation":0,"variations":[false,true],"salt":"salty"})"; +} + +// A segment rule that matches a user whose key is "alice". +char const* kRuleMatchesAlice = + R"({"id":"seg-rule","clauses":[{"attribute":"key","op":"in",)" + R"("values":["alice"],"contextKind":"user"}]})"; + +class BigSegmentEvaluatorTest : public ::testing::Test { + public: + BigSegmentEvaluatorTest() + : logger_(logging::NullLogger()), + fake_(std::make_shared()) { + store_.Init({}); + } + + void UpsertFlag(std::string const& json) { + store_.Upsert("flag", test_store::Flag(json.c_str())); + } + + void UpsertSegment(std::string const& key, std::string const& json) { + store_.Upsert(key, test_store::Segment(json.c_str())); + } + + // Builds a wrapper over the fake store and an evaluator that uses it. + evaluation::Evaluator EvaluatorWithStore() { + built::BigSegmentsConfig config; + config.store = fake_; + config.context_cache_size = 1000; + config.context_cache_time = 5s; + config.status_poll_interval = 5s; + config.stale_after = 2min; + wrapper_ = std::make_shared( + config, ioc_.get_executor(), logger_); + return evaluation::Evaluator{logger_, store_, wrapper_.get()}; + } + + // An evaluator with no Big Segment store configured. + evaluation::Evaluator EvaluatorWithoutStore() { + return evaluation::Evaluator{logger_, store_}; + } + + protected: + Logger logger_; + boost::asio::io_context ioc_; + data_components::MemoryStore store_; + std::shared_ptr fake_; + std::shared_ptr wrapper_; +}; + +Context AliceUser() { + return ContextBuilder().Kind("user", "alice").Build(); +} + +TEST_F(BigSegmentEvaluatorTest, NoStoreConfiguredIsNotConfigured) { + UpsertSegment("bigseg", UnboundedSegment("bigseg", "user", true, "")); + UpsertFlag(FlagMatchingSegments({"bigseg"})); + + auto eval = EvaluatorWithoutStore(); + auto detail = + eval.Evaluate(store_.GetFlag("flag")->item.value(), AliceUser()); + + EXPECT_EQ(*detail, Value(true)); // No match -> fallthrough. + EXPECT_EQ(detail.Reason()->BigSegmentsStatus(), + EvaluationReason::BigSegmentsStatus::kNotConfigured); +} + +TEST_F(BigSegmentEvaluatorTest, MissingGenerationIsNotConfigured) { + UpsertSegment("bigseg", UnboundedSegment("bigseg", "user", + /*with_generation=*/false, "")); + UpsertFlag(FlagMatchingSegments({"bigseg"})); + + auto eval = EvaluatorWithStore(); + auto detail = + eval.Evaluate(store_.GetFlag("flag")->item.value(), AliceUser()); + + EXPECT_EQ(*detail, Value(true)); + EXPECT_EQ(detail.Reason()->BigSegmentsStatus(), + EvaluationReason::BigSegmentsStatus::kNotConfigured); + EXPECT_EQ(fake_->MembershipCalls(), 0); // Never queried the store. +} + +TEST_F(BigSegmentEvaluatorTest, ContextLacksUnboundedKindDoesNotQuery) { + UpsertSegment("bigseg", UnboundedSegment("bigseg", "org", true, "")); + UpsertFlag(FlagMatchingSegments({"bigseg"})); + + auto eval = EvaluatorWithStore(); + // Context has no "org" kind, so there is no key to look up. + auto detail = + eval.Evaluate(store_.GetFlag("flag")->item.value(), AliceUser()); + + EXPECT_EQ(*detail, Value(true)); + EXPECT_EQ(detail.Reason()->BigSegmentsStatus(), + EvaluationReason::BigSegmentsStatus::kNone); + EXPECT_EQ(fake_->MembershipCalls(), 0); +} + +TEST_F(BigSegmentEvaluatorTest, IncludedMembershipMatches) { + UpsertSegment("bigseg", UnboundedSegment("bigseg", "user", true, "")); + UpsertFlag(FlagMatchingSegments({"bigseg"})); + fake_->PushMembership(Included("bigseg.g1")); + + auto eval = EvaluatorWithStore(); + auto detail = + eval.Evaluate(store_.GetFlag("flag")->item.value(), AliceUser()); + + EXPECT_EQ(*detail, Value(false)); // Segment match -> rule variation. + EXPECT_EQ(detail.Reason()->Kind(), EvaluationReason::Kind::kRuleMatch); + EXPECT_EQ(detail.Reason()->BigSegmentsStatus(), + EvaluationReason::BigSegmentsStatus::kHealthy); +} + +TEST_F(BigSegmentEvaluatorTest, ExcludedMembershipDoesNotMatch) { + UpsertSegment("bigseg", UnboundedSegment("bigseg", "user", true, "")); + UpsertFlag(FlagMatchingSegments({"bigseg"})); + fake_->PushMembership(Excluded("bigseg.g1")); + + auto eval = EvaluatorWithStore(); + auto detail = + eval.Evaluate(store_.GetFlag("flag")->item.value(), AliceUser()); + + EXPECT_EQ(*detail, Value(true)); // Excluded -> fallthrough. + EXPECT_EQ(detail.Reason()->BigSegmentsStatus(), + EvaluationReason::BigSegmentsStatus::kHealthy); +} + +TEST_F(BigSegmentEvaluatorTest, NoMembershipEntryFallsThroughToRules) { + UpsertSegment("bigseg", + UnboundedSegment("bigseg", "user", true, kRuleMatchesAlice)); + UpsertFlag(FlagMatchingSegments({"bigseg"})); + // Membership has no entry for bigseg.g1, so the segment's rules decide. + fake_->PushMembership(integrations::Membership::FromSegmentRefs({}, {})); + + auto eval = EvaluatorWithStore(); + auto detail = + eval.Evaluate(store_.GetFlag("flag")->item.value(), AliceUser()); + + EXPECT_EQ(*detail, Value(false)); // Segment rule matches alice. + EXPECT_EQ(detail.Reason()->Kind(), EvaluationReason::Kind::kRuleMatch); + EXPECT_EQ(detail.Reason()->BigSegmentsStatus(), + EvaluationReason::BigSegmentsStatus::kHealthy); +} + +TEST_F(BigSegmentEvaluatorTest, RegularIncludeListIgnoredForBigSegment) { + // A big segment's regular include list must be ignored; only store + // membership (and then the segment's rules) decide matching. + std::string const segment = + R"({"key":"bigseg","unbounded":true,"unboundedContextKind":"user",)" + R"("included":["alice"],"excluded":[],"rules":[],"salt":"salty",)" + R"("version":1,"generation":1})"; + UpsertSegment("bigseg", segment); + UpsertFlag(FlagMatchingSegments({"bigseg"})); + // The store has no entry for alice, so the regular include list must not + // produce a match. + fake_->PushMembership(integrations::Membership::FromSegmentRefs({}, {})); + + auto eval = EvaluatorWithStore(); + auto detail = + eval.Evaluate(store_.GetFlag("flag")->item.value(), AliceUser()); + + EXPECT_EQ(*detail, Value(true)); // Include list ignored -> fallthrough. + EXPECT_EQ(detail.Reason()->BigSegmentsStatus(), + EvaluationReason::BigSegmentsStatus::kHealthy); +} + +TEST_F(BigSegmentEvaluatorTest, StaleStoreReportsStale) { + UpsertSegment("bigseg", UnboundedSegment("bigseg", "user", true, "")); + UpsertFlag(FlagMatchingSegments({"bigseg"})); + fake_->PushMembership(Included("bigseg.g1")); + // Last update older than stale_after (2min) -> stale. + fake_->SetMetadata( + integrations::StoreMetadata{std::chrono::system_clock::now() - 1h}); + + auto eval = EvaluatorWithStore(); + auto detail = + eval.Evaluate(store_.GetFlag("flag")->item.value(), AliceUser()); + + EXPECT_EQ(*detail, Value(false)); // Still matches; only trust differs. + EXPECT_EQ(detail.Reason()->BigSegmentsStatus(), + EvaluationReason::BigSegmentsStatus::kStale); +} + +TEST_F(BigSegmentEvaluatorTest, StoreErrorReportsStoreError) { + UpsertSegment("bigseg", UnboundedSegment("bigseg", "user", true, "")); + UpsertFlag(FlagMatchingSegments({"bigseg"})); + fake_->PushMembership(tl::make_unexpected(std::string("boom"))); + + auto eval = EvaluatorWithStore(); + auto detail = + eval.Evaluate(store_.GetFlag("flag")->item.value(), AliceUser()); + + EXPECT_EQ(*detail, Value(true)); // Empty membership -> fallthrough. + EXPECT_EQ(detail.Reason()->BigSegmentsStatus(), + EvaluationReason::BigSegmentsStatus::kStoreError); +} + +TEST_F(BigSegmentEvaluatorTest, StatusResolvesByWorstPrecedence) { + UpsertSegment("bigsegA", UnboundedSegment("bigsegA", "user", true, "")); + UpsertSegment("bigsegB", UnboundedSegment("bigsegB", "org", true, "")); + UpsertFlag(FlagMatchingSegments({"bigsegA", "bigsegB"})); + // First lookup (user key) succeeds with no entry -> HEALTHY; second lookup + // (org key) errors -> STORE_ERROR. STORE_ERROR must win. + fake_->PushMembership(integrations::Membership::FromSegmentRefs({}, {})); + fake_->PushMembership(tl::make_unexpected(std::string("boom"))); + + auto context = + ContextBuilder().Kind("user", "alice").Kind("org", "org1").Build(); + auto eval = EvaluatorWithStore(); + auto detail = eval.Evaluate(store_.GetFlag("flag")->item.value(), context); + + EXPECT_EQ(*detail, Value(true)); // Neither segment matched. + EXPECT_EQ(detail.Reason()->BigSegmentsStatus(), + EvaluationReason::BigSegmentsStatus::kStoreError); + EXPECT_EQ(fake_->MembershipCalls(), 2); +} + +TEST_F(BigSegmentEvaluatorTest, QueriesStoreOncePerContextKey) { + // Two big segments of the same kind resolve to the same context key. + UpsertSegment("bigsegA", UnboundedSegment("bigsegA", "user", true, "")); + UpsertSegment("bigsegB", UnboundedSegment("bigsegB", "user", true, "")); + UpsertFlag(FlagMatchingSegments({"bigsegA", "bigsegB"})); + fake_->PushMembership(integrations::Membership::FromSegmentRefs({}, {})); + + auto eval = EvaluatorWithStore(); + auto detail = + eval.Evaluate(store_.GetFlag("flag")->item.value(), AliceUser()); + + EXPECT_EQ(*detail, Value(true)); + EXPECT_EQ(fake_->MembershipCalls(), 1); +} + +} // namespace