Skip to content

Commit 1135958

Browse files
[FSSDK-11176] Update: Implement Decision Service methods to handle CMAB (#369)
* update: Extend LRUCache with remove method and corresponding tests * update: Clean up whitespace in LRUCache implementation and tests * update: Extend copyright notice to include 2025 * update: Implement Default CMAB Service * update: Enable keyword initialization for CmabDecision and CmabCacheValue structs (otherwise breaks in ruby version change) * update: Refactor bucketing logic to handle empty traffic ranges and improve logging * update: Add support for CMAB traffic allocation in bucketing logic * update: Enhance DecisionService to support CMAB traffic allocation and decision retrieval * update: Integrate CMAB decision logic into DecisionService and update related tests * update: Refactor DecisionService to return DecisionResult struct instead of Decision struct * update: Integrate CMAB components into Project class and enhance decision handling * update: Refactor CMAB traffic allocation handling and enhance decision service error logging * update: Refactor OptimizelyDecision instantiation to use keyword arguments for clarity * update: Remove commented debug output from Optimizely user context spec * Trigger CI build --------- Co-authored-by: Matjaz Pirnovar <[email protected]>
1 parent adbd74e commit 1135958

File tree

6 files changed

+773
-331
lines changed

6 files changed

+773
-331
lines changed

lib/optimizely.rb

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,17 @@
4343
require_relative 'optimizely/odp/odp_manager'
4444
require_relative 'optimizely/helpers/sdk_settings'
4545
require_relative 'optimizely/user_profile_tracker'
46+
require_relative 'optimizely/cmab/cmab_client'
47+
require_relative 'optimizely/cmab/cmab_service'
4648

4749
module Optimizely
4850
class Project
4951
include Optimizely::Decide
5052

53+
# CMAB Constants
54+
DEFAULT_CMAB_CACHE_TIMEOUT = (30 * 60 * 1000)
55+
DEFAULT_CMAB_CACHE_SIZE = 1000
56+
5157
attr_reader :notification_center
5258
# @api no-doc
5359
attr_reader :config_manager, :decision_service, :error_handler, :event_dispatcher,
@@ -131,7 +137,19 @@ def initialize(
131137

132138
setup_odp!(@config_manager.sdk_key)
133139

134-
@decision_service = DecisionService.new(@logger, @user_profile_service)
140+
# Initialize CMAB components
141+
@cmab_client = DefaultCmabClient.new(
142+
retry_config: CmabRetryConfig.new,
143+
logger: @logger
144+
)
145+
@cmab_cache = LRUCache.new(DEFAULT_CMAB_CACHE_SIZE, DEFAULT_CMAB_CACHE_TIMEOUT)
146+
@cmab_service = DefaultCmabService.new(
147+
@cmab_cache,
148+
@cmab_client,
149+
@logger
150+
)
151+
152+
@decision_service = DecisionService.new(@logger, @cmab_service, @user_profile_service)
135153

136154
@event_processor = if event_processor.respond_to?(:process)
137155
event_processor
@@ -337,7 +355,7 @@ def decide_for_keys(user_context, keys, decide_options = [], ignore_default_opti
337355

338356
# If the feature flag is nil, create a default OptimizelyDecision and move to the next key
339357
if feature_flag.nil?
340-
decisions[key] = OptimizelyDecision.new(nil, false, nil, nil, key, user_context, [])
358+
decisions[key] = OptimizelyDecision.new(variation_key: nil, enabled: false, variables: nil, rule_key: nil, flag_key: key, user_context: user_context, reasons: [])
341359
next
342360
end
343361
valid_keys.push(key)
@@ -358,9 +376,17 @@ def decide_for_keys(user_context, keys, decide_options = [], ignore_default_opti
358376
decision_list = @decision_service.get_variations_for_feature_list(config, flags_without_forced_decision, user_context, decide_options)
359377

360378
flags_without_forced_decision.each_with_index do |flag, i|
361-
decision = decision_list[i][0]
362-
reasons = decision_list[i][1]
379+
decision = decision_list[i].decision
380+
reasons = decision_list[i].reasons
381+
error = decision_list[i].error
363382
flag_key = flag['key']
383+
# store error decision against key and remove key from valid keys
384+
if error
385+
optimizely_decision = OptimizelyDecision.new_error_decision(flag_key, user_context, reasons)
386+
decisions[flag_key] = optimizely_decision
387+
valid_keys.delete(flag_key) if valid_keys.include?(flag_key)
388+
next
389+
end
364390
flag_decisions[flag_key] = decision
365391
decision_reasons_dict[flag_key] ||= []
366392
decision_reasons_dict[flag_key].push(*reasons)
@@ -599,8 +625,8 @@ def is_feature_enabled(feature_flag_key, user_id, attributes = nil)
599625
end
600626

601627
user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false)
602-
decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
603-
628+
decision_result = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
629+
decision = decision_result.decision
604630
feature_enabled = false
605631
source_string = Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT']
606632
if decision.is_a?(Optimizely::DecisionService::Decision)
@@ -839,7 +865,8 @@ def get_all_feature_variables(feature_flag_key, user_id, attributes = nil)
839865
end
840866

841867
user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false)
842-
decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
868+
decision_result = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
869+
decision = decision_result.decision
843870
variation = decision ? decision['variation'] : nil
844871
feature_enabled = variation ? variation['featureEnabled'] : false
845872
all_variables = {}
@@ -1029,7 +1056,8 @@ def get_variation_with_config(experiment_key, user_id, attributes, config)
10291056
user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false)
10301057
user_profile_tracker = UserProfileTracker.new(user_id, @user_profile_service, @logger)
10311058
user_profile_tracker.load_user_profile
1032-
variation_id, = @decision_service.get_variation(config, experiment_id, user_context, user_profile_tracker)
1059+
variation_result = @decision_service.get_variation(config, experiment_id, user_context, user_profile_tracker)
1060+
variation_id = variation_result.variation_id
10331061
user_profile_tracker.save_user_profile
10341062
variation = config.get_variation_from_id(experiment_key, variation_id) unless variation_id.nil?
10351063
variation_key = variation['key'] if variation
@@ -1097,7 +1125,8 @@ def get_feature_variable_for_type(feature_flag_key, variable_key, variable_type,
10971125
end
10981126

10991127
user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false)
1100-
decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
1128+
decision_result = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
1129+
decision = decision_result.decision
11011130
variation = decision ? decision['variation'] : nil
11021131
feature_enabled = variation ? variation['featureEnabled'] : false
11031132

lib/optimizely/bucketer.rb

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,25 @@ def bucket(project_config, experiment, bucketing_id, user_id)
4444
# user_id - String ID for user.
4545
#
4646
# Returns variation in which visitor with ID user_id has been placed. Nil if no variation.
47+
48+
variation_id, decide_reasons = bucket_to_entity_id(project_config, experiment, bucketing_id, user_id)
49+
if variation_id && variation_id != ''
50+
experiment_id = experiment['id']
51+
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
52+
return variation, decide_reasons
53+
end
54+
55+
# Handle the case when the traffic range is empty due to sticky bucketing
56+
if variation_id == ''
57+
message = 'Bucketed into an empty traffic range. Returning nil.'
58+
@logger.log(Logger::DEBUG, message)
59+
decide_reasons.push(message)
60+
end
61+
62+
[nil, decide_reasons]
63+
end
64+
65+
def bucket_to_entity_id(project_config, experiment, bucketing_id, user_id)
4766
return nil, [] if experiment.nil?
4867

4968
decide_reasons = []
@@ -84,22 +103,18 @@ def bucket(project_config, experiment, bucketing_id, user_id)
84103
end
85104

86105
traffic_allocations = experiment['trafficAllocation']
106+
if experiment['cmab']
107+
traffic_allocations = [
108+
{
109+
'entityId' => '$',
110+
'endOfRange' => experiment['cmab']['trafficAllocation']
111+
}
112+
]
113+
end
87114
variation_id, find_bucket_reasons = find_bucket(bucketing_id, user_id, experiment_id, traffic_allocations)
88115
decide_reasons.push(*find_bucket_reasons)
89116

90-
if variation_id && variation_id != ''
91-
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
92-
return variation, decide_reasons
93-
end
94-
95-
# Handle the case when the traffic range is empty due to sticky bucketing
96-
if variation_id == ''
97-
message = 'Bucketed into an empty traffic range. Returning nil.'
98-
@logger.log(Logger::DEBUG, message)
99-
decide_reasons.push(message)
100-
end
101-
102-
[nil, decide_reasons]
117+
[variation_id, decide_reasons]
103118
end
104119

105120
def find_bucket(bucketing_id, user_id, parent_id, traffic_allocations)

lib/optimizely/decide/optimizely_decision.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,25 @@ def as_json
5555
def to_json(*args)
5656
as_json.to_json(*args)
5757
end
58+
59+
# Create a new OptimizelyDecision representing an error state.
60+
#
61+
# @param key [String] The flag key
62+
# @param user [OptimizelyUserContext] The user context
63+
# @param reasons [Array<String>] List of reasons explaining the error
64+
#
65+
# @return [OptimizelyDecision] OptimizelyDecision with error state values
66+
def self.new_error_decision(key, user, reasons = [])
67+
new(
68+
variation_key: nil,
69+
enabled: false,
70+
variables: {},
71+
rule_key: nil,
72+
flag_key: key,
73+
user_context: user,
74+
reasons: reasons
75+
)
76+
end
5877
end
5978
end
6079
end

0 commit comments

Comments
 (0)