Skip to content

Commit da64665

Browse files
[FSSDK-11167] Implement CMAB service (#367)
* 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)
1 parent b210c55 commit da64665

File tree

5 files changed

+392
-1
lines changed

5 files changed

+392
-1
lines changed

lib/optimizely/cmab/cmab_service.rb

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# frozen_string_literal: true
2+
3+
#
4+
# Copyright 2025 Optimizely and contributors
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
require 'optimizely/odp/lru_cache'
19+
require 'optimizely/decide/optimizely_decide_option'
20+
require 'digest'
21+
require 'json'
22+
require 'securerandom'
23+
24+
module Optimizely
25+
CmabDecision = Struct.new(:variation_id, :cmab_uuid, keyword_init: true)
26+
CmabCacheValue = Struct.new(:attributes_hash, :variation_id, :cmab_uuid, keyword_init: true)
27+
28+
# Default CMAB service implementation
29+
class DefaultCmabService
30+
# Initializes a new instance of the CmabService.
31+
#
32+
# @param cmab_cache [LRUCache] The cache object used for storing CMAB data. Must be an instance of LRUCache.
33+
# @param cmab_client [DefaultCmabClient] The client used to interact with the CMAB service. Must be an instance of DefaultCmabClient.
34+
# @param logger [Logger, nil] Optional logger for logging messages. Defaults to nil.
35+
#
36+
# @raise [ArgumentError] If cmab_cache is not an instance of LRUCache.
37+
# @raise [ArgumentError] If cmab_client is not an instance of DefaultCmabClient.
38+
def initialize(cmab_cache, cmab_client, logger = nil)
39+
@cmab_cache = cmab_cache
40+
@cmab_client = cmab_client
41+
@logger = logger
42+
end
43+
44+
def get_decision(project_config, user_context, rule_id, options)
45+
# Retrieves a decision for a given user and rule, utilizing a cache for efficiency.
46+
#
47+
# This method filters user attributes, checks for various cache-related options,
48+
# and either fetches a fresh decision or returns a cached one if appropriate.
49+
# It supports options to ignore the cache, reset the cache, or invalidate a specific user's cache entry.
50+
#
51+
# @param project_config [Object] The project configuration object.
52+
# @param user_context [Object] The user context containing user_id and attributes.
53+
# @param rule_id [String] The identifier for the decision rule.
54+
# @param options [Array<Symbol>, nil] Optional flags to control cache behavior. Supported options:
55+
# - OptimizelyDecideOption::IGNORE_CMAB_CACHE: Bypass cache and fetch a new decision.
56+
# - OptimizelyDecideOption::RESET_CMAB_CACHE: Reset the entire cache.
57+
# - OptimizelyDecideOption::INVALIDATE_USER_CMAB_CACHE: Invalidate cache for the specific user and rule.
58+
#
59+
# @return [CmabDecision] The decision object containing variation_id and cmab_uuid.
60+
61+
filtered_attributes = filter_attributes(project_config, user_context, rule_id)
62+
63+
return fetch_decision(rule_id, user_context.user_id, filtered_attributes) if options&.include?(Decide::OptimizelyDecideOption::IGNORE_CMAB_CACHE)
64+
65+
@cmab_cache.reset if options&.include?(Decide::OptimizelyDecideOption::RESET_CMAB_CACHE)
66+
67+
cache_key = get_cache_key(user_context.user_id, rule_id)
68+
69+
@cmab_cache.remove(cache_key) if options&.include?(Decide::OptimizelyDecideOption::INVALIDATE_USER_CMAB_CACHE)
70+
cached_value = @cmab_cache.lookup(cache_key)
71+
attributes_hash = hash_attributes(filtered_attributes)
72+
73+
if cached_value
74+
return CmabDecision.new(variation_id: cached_value.variation_id, cmab_uuid: cached_value.cmab_uuid) if cached_value.attributes_hash == attributes_hash
75+
76+
@cmab_cache.remove(cache_key)
77+
end
78+
cmab_decision = fetch_decision(rule_id, user_context.user_id, filtered_attributes)
79+
@cmab_cache.save(cache_key,
80+
CmabCacheValue.new(
81+
attributes_hash: attributes_hash,
82+
variation_id: cmab_decision.variation_id,
83+
cmab_uuid: cmab_decision.cmab_uuid
84+
))
85+
cmab_decision
86+
end
87+
88+
private
89+
90+
def fetch_decision(rule_id, user_id, attributes)
91+
# Fetches a decision for a given rule and user, along with user attributes.
92+
#
93+
# Generates a unique UUID for the decision request, then delegates to the CMAB client
94+
# to fetch the variation ID. Returns a CmabDecision object containing the variation ID
95+
# and the generated UUID.
96+
#
97+
# @param rule_id [String] The identifier for the rule to evaluate.
98+
# @param user_id [String] The identifier for the user.
99+
# @param attributes [Hash] A hash of user attributes to be used in decision making.
100+
# @return [CmabDecision] The decision object containing the variation ID and UUID.
101+
cmab_uuid = SecureRandom.uuid
102+
variation_id = @cmab_client.fetch_decision(rule_id, user_id, attributes, cmab_uuid)
103+
CmabDecision.new(variation_id: variation_id, cmab_uuid: cmab_uuid)
104+
end
105+
106+
def filter_attributes(project_config, user_context, rule_id)
107+
# Filters the user attributes based on the CMAB attribute IDs defined in the experiment.
108+
#
109+
# @param project_config [Object] The project configuration object containing experiment and attribute mappings.
110+
# @param user_context [Object] The user context object containing user attributes.
111+
# @param rule_id [String] The ID of the experiment (rule) to filter attributes for.
112+
# @return [Hash] A hash of filtered user attributes whose keys match the CMAB attribute IDs for the given experiment.
113+
user_attributes = user_context.user_attributes
114+
filtered_user_attributes = {}
115+
116+
experiment = project_config.experiment_id_map[rule_id]
117+
return filtered_user_attributes if experiment.nil? || experiment['cmab'].nil?
118+
119+
cmab_attribute_ids = experiment['cmab']['attributeIds']
120+
cmab_attribute_ids.each do |attribute_id|
121+
attribute = project_config.attribute_id_map[attribute_id]
122+
filtered_user_attributes[attribute.key] = user_attributes[attribute.key] if attribute && user_attributes.key?(attribute.key)
123+
end
124+
125+
filtered_user_attributes
126+
end
127+
128+
def get_cache_key(user_id, rule_id)
129+
# Generates a cache key string based on the provided user ID and rule ID.
130+
#
131+
# The cache key is constructed in the format: "<user_id_length>-<user_id>-<rule_id>",
132+
# where <user_id_length> is the length of the user_id string.
133+
#
134+
# @param user_id [String] The unique identifier for the user.
135+
# @param rule_id [String] The unique identifier for the rule.
136+
# @return [String] The generated cache key.
137+
"#{user_id.length}-#{user_id}-#{rule_id}"
138+
end
139+
140+
def hash_attributes(attributes)
141+
# Generates an MD5 hash for a given attributes hash.
142+
#
143+
# The method sorts the attributes by key, serializes them to a JSON string,
144+
# and then computes the MD5 hash of the resulting string. This ensures that
145+
# the hash is consistent regardless of the original key order in the input hash.
146+
#
147+
# @param attributes [Hash] The attributes to be hashed.
148+
# @return [String] The MD5 hash of the sorted and serialized attributes.
149+
sorted_attrs = JSON.generate(attributes.sort.to_h)
150+
Digest::MD5.hexdigest(sorted_attrs)
151+
end
152+
end
153+
end

lib/optimizely/config/datafile_project_config.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ class DatafileProjectConfig < ProjectConfig
2727
attr_reader :datafile, :account_id, :attributes, :audiences, :typed_audiences, :events,
2828
:experiments, :feature_flags, :groups, :project_id, :bot_filtering, :revision,
2929
:sdk_key, :environment_key, :rollouts, :version, :send_flag_decisions,
30-
:attribute_key_map, :attribute_id_to_key_map, :audience_id_map, :event_key_map, :experiment_feature_map,
30+
:attribute_key_map, :attribute_id_to_key_map, :attribute_id_map,
31+
:audience_id_map, :event_key_map, :experiment_feature_map,
3132
:experiment_id_map, :experiment_key_map, :feature_flag_key_map, :feature_variable_key_map,
3233
:group_id_map, :rollout_id_map, :rollout_experiment_id_map, :variation_id_map,
3334
:variation_id_to_variable_usage_map, :variation_key_map, :variation_id_map_by_experiment_id,
@@ -82,6 +83,7 @@ def initialize(datafile, logger, error_handler)
8283

8384
# Utility maps for quick lookup
8485
@attribute_key_map = generate_key_map(@attributes, 'key')
86+
@attribute_id_map = generate_key_map(@attributes, 'id')
8587
@attribute_id_to_key_map = {}
8688
@attributes.each do |attribute|
8789
@attribute_id_to_key_map[attribute['id']] = attribute['key']

lib/optimizely/decide/optimizely_decide_option.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ module OptimizelyDecideOption
2323
IGNORE_USER_PROFILE_SERVICE = 'IGNORE_USER_PROFILE_SERVICE'
2424
INCLUDE_REASONS = 'INCLUDE_REASONS'
2525
EXCLUDE_VARIABLES = 'EXCLUDE_VARIABLES'
26+
IGNORE_CMAB_CACHE = 'IGNORE_CMAB_CACHE'
27+
RESET_CMAB_CACHE = 'RESET_CMAB_CACHE'
28+
INVALIDATE_USER_CMAB_CACHE = 'INVALIDATE_USER_CMAB_CACHE'
2629
end
2730
end
2831
end
File renamed without changes.

0 commit comments

Comments
 (0)