From de619315922a10309045f161d793ce8a77b5b830 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 2 Jul 2025 05:48:26 +0600 Subject: [PATCH 1/5] update: Extend LRUCache with remove method and corresponding tests --- lib/optimizely/odp/lru_cache.rb | 15 ++++++- spec/odp/lru_cache_spec.rb | 80 +++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/lib/optimizely/odp/lru_cache.rb b/lib/optimizely/odp/lru_cache.rb index 8ce61549..23bf4e67 100644 --- a/lib/optimizely/odp/lru_cache.rb +++ b/lib/optimizely/odp/lru_cache.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # -# Copyright 2022, Optimizely and contributors +# Copyright 2022-2025, Optimizely and contributors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -91,6 +91,19 @@ def peek(key) @cache_mutex.synchronize { @map[key]&.value } end + + # Remove the element associated with the provided key from the cache + # + # @param key - The key to remove + + def remove(key) + return if @capacity <= 0 + + @cache_mutex.synchronize do + @map.delete(key) + end + nil + end end class CacheElement diff --git a/spec/odp/lru_cache_spec.rb b/spec/odp/lru_cache_spec.rb index 46363c8b..deafeecc 100644 --- a/spec/odp/lru_cache_spec.rb +++ b/spec/odp/lru_cache_spec.rb @@ -149,4 +149,84 @@ cache.save('cow', 'crate') expect(cache.lookup('cow')).to eq 'crate' end + + it 'should remove existing key' do + cache = Optimizely::LRUCache.new(3, 1000) + + cache.save('1', 100) + cache.save('2', 200) + cache.save('3', 300) + + expect(cache.lookup('1')).to eq 100 + expect(cache.lookup('2')).to eq 200 + expect(cache.lookup('3')).to eq 300 + + cache.remove('2') + + expect(cache.lookup('1')).to eq 100 + expect(cache.lookup('2')).to be_nil + expect(cache.lookup('3')).to eq 300 + end + + it 'should handle removing non-existent key' do + cache = Optimizely::LRUCache.new(3, 1000) + cache.save('1', 100) + cache.save('2', 200) + + cache.remove('3') # Doesn't exist + + expect(cache.lookup('1')).to eq 100 + expect(cache.lookup('2')).to eq 200 + end + + it 'should handle removing from zero sized cache' do + cache = Optimizely::LRUCache.new(0, 1000) + cache.save('1', 100) + cache.remove('1') + + expect(cache.lookup('1')).to be_nil + end + + it 'should handle removing and adding back a key' do + cache = Optimizely::LRUCache.new(3, 1000) + cache.save('1', 100) + cache.save('2', 200) + cache.save('3', 300) + + cache.remove('2') + cache.save('2', 201) + + expect(cache.lookup('1')).to eq 100 + expect(cache.lookup('2')).to eq 201 + expect(cache.lookup('3')).to eq 300 + end + + it 'should handle thread safety' do + max_size = 100 + cache = Optimizely::LRUCache.new(max_size, 1000) + + (1..max_size).each do |i| + cache.save(i.to_s, i * 100) + end + + threads = [] + (1..(max_size / 2)).each do |i| + thread = Thread.new do + cache.remove(i.to_s) + end + threads << thread + end + + threads.each(&:join) + + (1..max_size).each do |i| + if i <= max_size / 2 + expect(cache.lookup(i.to_s)).to be_nil + else + expect(cache.lookup(i.to_s)).to eq(i * 100) + end + end + + expect(cache.instance_variable_get('@map').size).to eq(max_size / 2) + end end From 01e3a9f8545afd2b9700be5975a327bb57b7355a Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 2 Jul 2025 05:52:03 +0600 Subject: [PATCH 2/5] update: Clean up whitespace in LRUCache implementation and tests --- lib/optimizely/odp/lru_cache.rb | 2 +- spec/odp/lru_cache_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/optimizely/odp/lru_cache.rb b/lib/optimizely/odp/lru_cache.rb index 23bf4e67..6d4c9af3 100644 --- a/lib/optimizely/odp/lru_cache.rb +++ b/lib/optimizely/odp/lru_cache.rb @@ -91,7 +91,7 @@ def peek(key) @cache_mutex.synchronize { @map[key]&.value } end - + # Remove the element associated with the provided key from the cache # # @param key - The key to remove diff --git a/spec/odp/lru_cache_spec.rb b/spec/odp/lru_cache_spec.rb index deafeecc..8c65d909 100644 --- a/spec/odp/lru_cache_spec.rb +++ b/spec/odp/lru_cache_spec.rb @@ -173,7 +173,7 @@ cache.save('1', 100) cache.save('2', 200) - cache.remove('3') # Doesn't exist + cache.remove('3') # Doesn't exist expect(cache.lookup('1')).to eq 100 expect(cache.lookup('2')).to eq 200 From 40d0571ca4cf01f12d497b7851a335995b092114 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 2 Jul 2025 05:52:38 +0600 Subject: [PATCH 3/5] update: Extend copyright notice to include 2025 --- spec/odp/lru_cache_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/odp/lru_cache_spec.rb b/spec/odp/lru_cache_spec.rb index 8c65d909..32db021f 100644 --- a/spec/odp/lru_cache_spec.rb +++ b/spec/odp/lru_cache_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # -# Copyright 2022, Optimizely and contributors +# Copyright 2022-2025, Optimizely and contributors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. From 2282eb12e4bc46df7772d2b55acdfe816cd9c49a Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 9 Jul 2025 17:47:31 +0600 Subject: [PATCH 4/5] update: Implement Default CMAB Service --- lib/optimizely/cmab/cmab_service.rb | 153 ++++++++++++ .../config/datafile_project_config.rb | 4 +- .../decide/optimizely_decide_option.rb | 3 + spec/{ => cmab}/cmab_client_spec.rb | 0 spec/cmab/cmab_service_spec.rb | 233 ++++++++++++++++++ 5 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 lib/optimizely/cmab/cmab_service.rb rename spec/{ => cmab}/cmab_client_spec.rb (100%) create mode 100644 spec/cmab/cmab_service_spec.rb diff --git a/lib/optimizely/cmab/cmab_service.rb b/lib/optimizely/cmab/cmab_service.rb new file mode 100644 index 00000000..134b9fb8 --- /dev/null +++ b/lib/optimizely/cmab/cmab_service.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +# +# Copyright 2025 Optimizely and contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +require 'optimizely/odp/lru_cache' +require 'optimizely/decide/optimizely_decide_option' +require 'digest' +require 'json' +require 'securerandom' + +module Optimizely + CmabDecision = Struct.new(:variation_id, :cmab_uuid) + CmabCacheValue = Struct.new(:attributes_hash, :variation_id, :cmab_uuid) + + # Default CMAB service implementation + class DefaultCmabService + # Initializes a new instance of the CmabService. + # + # @param cmab_cache [LRUCache] The cache object used for storing CMAB data. Must be an instance of LRUCache. + # @param cmab_client [DefaultCmabClient] The client used to interact with the CMAB service. Must be an instance of DefaultCmabClient. + # @param logger [Logger, nil] Optional logger for logging messages. Defaults to nil. + # + # @raise [ArgumentError] If cmab_cache is not an instance of LRUCache. + # @raise [ArgumentError] If cmab_client is not an instance of DefaultCmabClient. + def initialize(cmab_cache, cmab_client, logger = nil) + @cmab_cache = cmab_cache + @cmab_client = cmab_client + @logger = logger + end + + def get_decision(project_config, user_context, rule_id, options) + # Retrieves a decision for a given user and rule, utilizing a cache for efficiency. + # + # This method filters user attributes, checks for various cache-related options, + # and either fetches a fresh decision or returns a cached one if appropriate. + # It supports options to ignore the cache, reset the cache, or invalidate a specific user's cache entry. + # + # @param project_config [Object] The project configuration object. + # @param user_context [Object] The user context containing user_id and attributes. + # @param rule_id [String] The identifier for the decision rule. + # @param options [Array, nil] Optional flags to control cache behavior. Supported options: + # - OptimizelyDecideOption::IGNORE_CMAB_CACHE: Bypass cache and fetch a new decision. + # - OptimizelyDecideOption::RESET_CMAB_CACHE: Reset the entire cache. + # - OptimizelyDecideOption::INVALIDATE_USER_CMAB_CACHE: Invalidate cache for the specific user and rule. + # + # @return [CmabDecision] The decision object containing variation_id and cmab_uuid. + + filtered_attributes = filter_attributes(project_config, user_context, rule_id) + + return fetch_decision(rule_id, user_context.user_id, filtered_attributes) if options&.include?(Decide::OptimizelyDecideOption::IGNORE_CMAB_CACHE) + + @cmab_cache.reset if options&.include?(Decide::OptimizelyDecideOption::RESET_CMAB_CACHE) + + cache_key = get_cache_key(user_context.user_id, rule_id) + + @cmab_cache.remove(cache_key) if options&.include?(Decide::OptimizelyDecideOption::INVALIDATE_USER_CMAB_CACHE) + cached_value = @cmab_cache.lookup(cache_key) + attributes_hash = hash_attributes(filtered_attributes) + + if cached_value + return CmabDecision.new(variation_id: cached_value.variation_id, cmab_uuid: cached_value.cmab_uuid) if cached_value.attributes_hash == attributes_hash + + @cmab_cache.remove(cache_key) + end + cmab_decision = fetch_decision(rule_id, user_context.user_id, filtered_attributes) + @cmab_cache.save(cache_key, + CmabCacheValue.new( + attributes_hash: attributes_hash, + variation_id: cmab_decision.variation_id, + cmab_uuid: cmab_decision.cmab_uuid + )) + cmab_decision + end + + private + + def fetch_decision(rule_id, user_id, attributes) + # Fetches a decision for a given rule and user, along with user attributes. + # + # Generates a unique UUID for the decision request, then delegates to the CMAB client + # to fetch the variation ID. Returns a CmabDecision object containing the variation ID + # and the generated UUID. + # + # @param rule_id [String] The identifier for the rule to evaluate. + # @param user_id [String] The identifier for the user. + # @param attributes [Hash] A hash of user attributes to be used in decision making. + # @return [CmabDecision] The decision object containing the variation ID and UUID. + cmab_uuid = SecureRandom.uuid + variation_id = @cmab_client.fetch_decision(rule_id, user_id, attributes, cmab_uuid) + CmabDecision.new(variation_id: variation_id, cmab_uuid: cmab_uuid) + end + + def filter_attributes(project_config, user_context, rule_id) + # Filters the user attributes based on the CMAB attribute IDs defined in the experiment. + # + # @param project_config [Object] The project configuration object containing experiment and attribute mappings. + # @param user_context [Object] The user context object containing user attributes. + # @param rule_id [String] The ID of the experiment (rule) to filter attributes for. + # @return [Hash] A hash of filtered user attributes whose keys match the CMAB attribute IDs for the given experiment. + user_attributes = user_context.user_attributes + filtered_user_attributes = {} + + experiment = project_config.experiment_id_map[rule_id] + return filtered_user_attributes if experiment.nil? || experiment['cmab'].nil? + + cmab_attribute_ids = experiment['cmab']['attributeIds'] + cmab_attribute_ids.each do |attribute_id| + attribute = project_config.attribute_id_map[attribute_id] + filtered_user_attributes[attribute.key] = user_attributes[attribute.key] if attribute && user_attributes.key?(attribute.key) + end + + filtered_user_attributes + end + + def get_cache_key(user_id, rule_id) + # Generates a cache key string based on the provided user ID and rule ID. + # + # The cache key is constructed in the format: "--", + # where is the length of the user_id string. + # + # @param user_id [String] The unique identifier for the user. + # @param rule_id [String] The unique identifier for the rule. + # @return [String] The generated cache key. + "#{user_id.length}-#{user_id}-#{rule_id}" + end + + def hash_attributes(attributes) + # Generates an MD5 hash for a given attributes hash. + # + # The method sorts the attributes by key, serializes them to a JSON string, + # and then computes the MD5 hash of the resulting string. This ensures that + # the hash is consistent regardless of the original key order in the input hash. + # + # @param attributes [Hash] The attributes to be hashed. + # @return [String] The MD5 hash of the sorted and serialized attributes. + sorted_attrs = JSON.generate(attributes.sort.to_h) + Digest::MD5.hexdigest(sorted_attrs) + end + end +end diff --git a/lib/optimizely/config/datafile_project_config.rb b/lib/optimizely/config/datafile_project_config.rb index 1f03171d..51673e23 100644 --- a/lib/optimizely/config/datafile_project_config.rb +++ b/lib/optimizely/config/datafile_project_config.rb @@ -27,7 +27,8 @@ class DatafileProjectConfig < ProjectConfig attr_reader :datafile, :account_id, :attributes, :audiences, :typed_audiences, :events, :experiments, :feature_flags, :groups, :project_id, :bot_filtering, :revision, :sdk_key, :environment_key, :rollouts, :version, :send_flag_decisions, - :attribute_key_map, :attribute_id_to_key_map, :audience_id_map, :event_key_map, :experiment_feature_map, + :attribute_key_map, :attribute_id_to_key_map, :attribute_id_map, + :audience_id_map, :event_key_map, :experiment_feature_map, :experiment_id_map, :experiment_key_map, :feature_flag_key_map, :feature_variable_key_map, :group_id_map, :rollout_id_map, :rollout_experiment_id_map, :variation_id_map, :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) # Utility maps for quick lookup @attribute_key_map = generate_key_map(@attributes, 'key') + @attribute_id_map = generate_key_map(@attributes, 'id') @attribute_id_to_key_map = {} @attributes.each do |attribute| @attribute_id_to_key_map[attribute['id']] = attribute['key'] diff --git a/lib/optimizely/decide/optimizely_decide_option.rb b/lib/optimizely/decide/optimizely_decide_option.rb index f89dcd51..1b6781c2 100644 --- a/lib/optimizely/decide/optimizely_decide_option.rb +++ b/lib/optimizely/decide/optimizely_decide_option.rb @@ -23,6 +23,9 @@ module OptimizelyDecideOption IGNORE_USER_PROFILE_SERVICE = 'IGNORE_USER_PROFILE_SERVICE' INCLUDE_REASONS = 'INCLUDE_REASONS' EXCLUDE_VARIABLES = 'EXCLUDE_VARIABLES' + IGNORE_CMAB_CACHE = 'IGNORE_CMAB_CACHE' + RESET_CMAB_CACHE = 'RESET_CMAB_CACHE' + INVALIDATE_USER_CMAB_CACHE = 'INVALIDATE_USER_CMAB_CACHE' end end end diff --git a/spec/cmab_client_spec.rb b/spec/cmab/cmab_client_spec.rb similarity index 100% rename from spec/cmab_client_spec.rb rename to spec/cmab/cmab_client_spec.rb diff --git a/spec/cmab/cmab_service_spec.rb b/spec/cmab/cmab_service_spec.rb new file mode 100644 index 00000000..6c3c0011 --- /dev/null +++ b/spec/cmab/cmab_service_spec.rb @@ -0,0 +1,233 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'optimizely/cmab/cmab_service' +require 'optimizely/odp/lru_cache' +require 'optimizely/cmab/cmab_client' +require 'optimizely/decide/optimizely_decide_option' + +describe Optimizely::DefaultCmabService do + let(:mock_cmab_cache) { instance_double(Optimizely::LRUCache) } + let(:mock_cmab_client) { instance_double(Optimizely::DefaultCmabClient) } + let(:mock_logger) { double('logger') } + let(:cmab_service) { described_class.new(mock_cmab_cache, mock_cmab_client, mock_logger) } + + let(:mock_project_config) { double('project_config') } + let(:mock_user_context) { double('user_context') } + let(:user_id) { 'user123' } + let(:rule_id) { 'exp1' } + let(:user_attributes) { {'age' => 25, 'location' => 'USA'} } + + let(:mock_experiment) { {'cmab' => {'attributeIds' => %w[66 77]}} } + let(:mock_attr1) { double('attribute', key: 'age') } + let(:mock_attr2) { double('attribute', key: 'location') } + + before do + allow(mock_user_context).to receive(:user_id).and_return(user_id) + allow(mock_user_context).to receive(:user_attributes).and_return(user_attributes) + + allow(mock_project_config).to receive(:experiment_id_map).and_return({rule_id => mock_experiment}) + allow(mock_project_config).to receive(:attribute_id_map).and_return({ + '66' => mock_attr1, + '77' => mock_attr2 + }) + end + + describe '#get_decision' do + it 'returns decision from cache when valid' do + expected_key = cmab_service.send(:get_cache_key, user_id, rule_id) + expected_attributes = {'age' => 25, 'location' => 'USA'} + expected_hash = cmab_service.send(:hash_attributes, expected_attributes) + + cached_value = Optimizely::CmabCacheValue.new( + attributes_hash: expected_hash, + variation_id: 'varA', + cmab_uuid: 'uuid-123' + ) + + allow(mock_cmab_cache).to receive(:lookup).with(expected_key).and_return(cached_value) + + decision = cmab_service.get_decision(mock_project_config, mock_user_context, rule_id, []) + + expect(mock_cmab_cache).to have_received(:lookup).with(expected_key) + expect(decision.variation_id).to eq('varA') + expect(decision.cmab_uuid).to eq('uuid-123') + end + + it 'ignores cache when option given' do + allow(mock_cmab_client).to receive(:fetch_decision).and_return('varB') + expected_attributes = {'age' => 25, 'location' => 'USA'} + + decision = cmab_service.get_decision( + mock_project_config, + mock_user_context, + rule_id, + [Optimizely::Decide::OptimizelyDecideOption::IGNORE_CMAB_CACHE] + ) + + expect(decision.variation_id).to eq('varB') + expect(decision.cmab_uuid).to be_a(String) + expect(mock_cmab_client).to have_received(:fetch_decision).with( + rule_id, + user_id, + expected_attributes, + decision.cmab_uuid + ) + end + + it 'invalidates user cache when option given' do + allow(mock_cmab_client).to receive(:fetch_decision).and_return('varC') + allow(mock_cmab_cache).to receive(:lookup).and_return(nil) + allow(mock_cmab_cache).to receive(:remove) + allow(mock_cmab_cache).to receive(:save) + + cmab_service.get_decision( + mock_project_config, + mock_user_context, + rule_id, + [Optimizely::Decide::OptimizelyDecideOption::INVALIDATE_USER_CMAB_CACHE] + ) + + key = cmab_service.send(:get_cache_key, user_id, rule_id) + expect(mock_cmab_cache).to have_received(:remove).with(key) + end + + it 'resets cache when option given' do + allow(mock_cmab_client).to receive(:fetch_decision).and_return('varD') + allow(mock_cmab_cache).to receive(:reset) + allow(mock_cmab_cache).to receive(:lookup).and_return(nil) + allow(mock_cmab_cache).to receive(:save) + + decision = cmab_service.get_decision( + mock_project_config, + mock_user_context, + rule_id, + [Optimizely::Decide::OptimizelyDecideOption::RESET_CMAB_CACHE] + ) + + expect(mock_cmab_cache).to have_received(:reset) + expect(decision.variation_id).to eq('varD') + expect(decision.cmab_uuid).to be_a(String) + end + + it 'fetches new decision when hash changes' do + old_cached_value = Optimizely::CmabCacheValue.new( + attributes_hash: 'old_hash', + variation_id: 'varA', + cmab_uuid: 'uuid-123' + ) + + allow(mock_cmab_cache).to receive(:lookup).and_return(old_cached_value) + allow(mock_cmab_cache).to receive(:remove) + allow(mock_cmab_cache).to receive(:save) + allow(mock_cmab_client).to receive(:fetch_decision).and_return('varE') + + expected_attributes = {'age' => 25, 'location' => 'USA'} + cmab_service.send(:hash_attributes, expected_attributes) + expected_key = cmab_service.send(:get_cache_key, user_id, rule_id) + + decision = cmab_service.get_decision(mock_project_config, mock_user_context, rule_id, []) + + expect(mock_cmab_cache).to have_received(:remove).with(expected_key) + expect(mock_cmab_cache).to have_received(:save).with( + expected_key, + an_instance_of(Optimizely::CmabCacheValue) + ) + expect(decision.variation_id).to eq('varE') + expect(mock_cmab_client).to have_received(:fetch_decision).with( + rule_id, + user_id, + expected_attributes, + decision.cmab_uuid + ) + end + + it 'only passes cmab attributes to client' do + allow(mock_user_context).to receive(:user_attributes).and_return({ + 'age' => 25, + 'location' => 'USA', + 'extra_attr' => 'value', + 'another_extra' => 123 + }) + allow(mock_cmab_client).to receive(:fetch_decision).and_return('varF') + + decision = cmab_service.get_decision( + mock_project_config, + mock_user_context, + rule_id, + [Optimizely::Decide::OptimizelyDecideOption::IGNORE_CMAB_CACHE] + ) + + # Verify only age and location are passed + expect(mock_cmab_client).to have_received(:fetch_decision).with( + rule_id, + user_id, + {'age' => 25, 'location' => 'USA'}, + decision.cmab_uuid + ) + end + end + + describe '#filter_attributes' do + it 'returns correct subset of attributes' do + filtered = cmab_service.send(:filter_attributes, mock_project_config, mock_user_context, rule_id) + + expect(filtered['age']).to eq(25) + expect(filtered['location']).to eq('USA') + end + + it 'returns empty hash when no cmab config' do + allow(mock_project_config).to receive(:experiment_id_map).and_return({rule_id => {'cmab' => nil}}) + + filtered = cmab_service.send(:filter_attributes, mock_project_config, mock_user_context, rule_id) + + expect(filtered).to eq({}) + end + + it 'returns empty hash when experiment not found' do + allow(mock_project_config).to receive(:experiment_id_map).and_return({}) + + filtered = cmab_service.send(:filter_attributes, mock_project_config, mock_user_context, rule_id) + + expect(filtered).to eq({}) + end + end + + describe '#hash_attributes' do + it 'produces stable output regardless of key order' do + attrs1 = {'b' => 2, 'a' => 1} + attrs2 = {'a' => 1, 'b' => 2} + + hash1 = cmab_service.send(:hash_attributes, attrs1) + hash2 = cmab_service.send(:hash_attributes, attrs2) + + expect(hash1).to eq(hash2) + end + end + + describe '#get_cache_key' do + it 'generates correct cache key format' do + key = cmab_service.send(:get_cache_key, 'user123', 'exp1') + + expect(key).to eq('7-user123-exp1') + end + end + + describe '#fetch_decision' do + it 'generates uuid and calls client' do + allow(mock_cmab_client).to receive(:fetch_decision).and_return('varX') + attributes = {'age' => 25} + + decision = cmab_service.send(:fetch_decision, rule_id, user_id, attributes) + + expect(decision.variation_id).to eq('varX') + expect(decision.cmab_uuid).to match(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/) + expect(mock_cmab_client).to have_received(:fetch_decision).with( + rule_id, + user_id, + attributes, + decision.cmab_uuid + ) + end + end +end From 8ca3aee221e83104335c7ca754aab40b6f7f5596 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 9 Jul 2025 21:23:18 +0600 Subject: [PATCH 5/5] update: Enable keyword initialization for CmabDecision and CmabCacheValue structs (otherwise breaks in ruby version change) --- lib/optimizely/cmab/cmab_service.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/optimizely/cmab/cmab_service.rb b/lib/optimizely/cmab/cmab_service.rb index 134b9fb8..b56a785b 100644 --- a/lib/optimizely/cmab/cmab_service.rb +++ b/lib/optimizely/cmab/cmab_service.rb @@ -22,8 +22,8 @@ require 'securerandom' module Optimizely - CmabDecision = Struct.new(:variation_id, :cmab_uuid) - CmabCacheValue = Struct.new(:attributes_hash, :variation_id, :cmab_uuid) + CmabDecision = Struct.new(:variation_id, :cmab_uuid, keyword_init: true) + CmabCacheValue = Struct.new(:attributes_hash, :variation_id, :cmab_uuid, keyword_init: true) # Default CMAB service implementation class DefaultCmabService