diff --git a/lib/core/decision_service/cmab/cmab_service.spec.ts b/lib/core/decision_service/cmab/cmab_service.spec.ts new file mode 100644 index 000000000..2e571932d --- /dev/null +++ b/lib/core/decision_service/cmab/cmab_service.spec.ts @@ -0,0 +1,420 @@ +import { describe, it, expect, vi, Mocked, Mock, MockInstance, beforeEach, afterEach } from 'vitest'; + +import { DefaultCmabService } from './cmab_service'; +import { getMockSyncCache } from '../../../tests/mock/mock_cache'; +import { ProjectConfig } from '../../../project_config/project_config'; +import { OptimizelyDecideOption, UserAttributes } from '../../../shared_types'; +import OptimizelyUserContext from '../../../optimizely_user_context'; +import { validate as uuidValidate } from 'uuid'; + +const mockProjectConfig = (): ProjectConfig => ({ + experimentIdMap: { + '1234': { + id: '1234', + key: 'cmab_1', + cmab: { + attributeIds: ['66', '77', '88'], + } + }, + '5678': { + id: '5678', + key: 'cmab_2', + cmab: { + attributeIds: ['66', '99'], + } + }, + }, + attributeIdMap: { + '66': { + id: '66', + key: 'country', + }, + '77': { + id: '77', + key: 'age', + }, + '88': { + id: '88', + key: 'language', + }, + '99': { + id: '99', + key: 'gender', + }, + } +} as any); + +const mockUserContext = (userId: string, attributes: UserAttributes): OptimizelyUserContext => new OptimizelyUserContext({ + userId, + attributes, +} as any); + +describe('DefaultCmabService', () => { + it('should fetch and return the variation from cmabClient using correct parameters', async () => { + const mockCmabClient = { + fetchDecision: vi.fn().mockResolvedValue('123'), + }; + + const cmabService = new DefaultCmabService({ + cmabCache: getMockSyncCache(), + cmabClient: mockCmabClient, + }); + + const projectConfig = mockProjectConfig(); + const userContext = mockUserContext('user123', { + country: 'US', + age: '25', + gender: 'male', + }); + + const ruleId = '1234'; + const variation = await cmabService.getDecision(projectConfig, userContext, ruleId, []); + + expect(variation.variationId).toEqual('123'); + expect(uuidValidate(variation.cmabUuid)).toBe(true); + + expect(mockCmabClient.fetchDecision).toHaveBeenCalledOnce(); + const [ruleIdArg, userIdArg, attributesArg, cmabUuidArg] = mockCmabClient.fetchDecision.mock.calls[0]; + expect(ruleIdArg).toEqual(ruleId); + expect(userIdArg).toEqual(userContext.getUserId()); + expect(attributesArg).toEqual({ + country: 'US', + age: '25', + }); + }); + + it('should filter attributes based on experiment cmab attributeIds before fetching variation', async () => { + const mockCmabClient = { + fetchDecision: vi.fn().mockResolvedValue('123'), + }; + + const cmabService = new DefaultCmabService({ + cmabCache: getMockSyncCache(), + cmabClient: mockCmabClient, + }); + + const projectConfig = mockProjectConfig(); + const userContext = mockUserContext('user123', { + country: 'US', + age: '25', + language: 'en', + gender: 'male' + }); + + await cmabService.getDecision(projectConfig, userContext, '1234', []); + await cmabService.getDecision(projectConfig, userContext, '5678', []); + + expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(2); + expect(mockCmabClient.fetchDecision.mock.calls[0][2]).toEqual({ + country: 'US', + age: '25', + language: 'en', + }); + expect(mockCmabClient.fetchDecision.mock.calls[1][2]).toEqual({ + country: 'US', + gender: 'male' + }); + }); + + it('should cache the variation and return the same variation if relevant attributes have not changed', async () => { + const mockCmabClient = { + fetchDecision: vi.fn().mockResolvedValueOnce('123') + .mockResolvedValueOnce('456') + .mockResolvedValueOnce('789'), + }; + + const cmabService = new DefaultCmabService({ + cmabCache: getMockSyncCache(), + cmabClient: mockCmabClient, + }); + + const projectConfig = mockProjectConfig(); + const userContext11 = mockUserContext('user123', { + country: 'US', + age: '25', + language: 'en', + gender: 'male' + }); + + const variation11 = await cmabService.getDecision(projectConfig, userContext11, '1234', []); + + const userContext12 = mockUserContext('user123', { + country: 'US', + age: '25', + language: 'en', + gender: 'female' + }); + + const variation12 = await cmabService.getDecision(projectConfig, userContext12, '1234', []); + expect(variation11.variationId).toEqual('123'); + expect(variation12.variationId).toEqual('123'); + expect(variation11.cmabUuid).toEqual(variation12.cmabUuid); + + expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(1); + + const userContext21 = mockUserContext('user456', { + country: 'BD', + age: '30', + }); + + const variation21 = await cmabService.getDecision(projectConfig, userContext21, '5678', []); + + const userContext22 = mockUserContext('user456', { + country: 'BD', + age: '35', + }); + + const variation22 = await cmabService.getDecision(projectConfig, userContext22, '5678', []); + expect(variation21.variationId).toEqual('456'); + expect(variation22.variationId).toEqual('456'); + expect(variation21.cmabUuid).toEqual(variation22.cmabUuid); + + expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(2); + }); + + it('should cache the variation and return the same variation if relevant attributes value have not changed but order changed', async () => { + const mockCmabClient = { + fetchDecision: vi.fn().mockResolvedValueOnce('123') + .mockResolvedValueOnce('456') + .mockResolvedValueOnce('789'), + }; + + const cmabService = new DefaultCmabService({ + cmabCache: getMockSyncCache(), + cmabClient: mockCmabClient, + }); + + const projectConfig = mockProjectConfig(); + const userContext11 = mockUserContext('user123', { + country: 'US', + age: '25', + language: 'en', + gender: 'male' + }); + + const variation11 = await cmabService.getDecision(projectConfig, userContext11, '1234', []); + + const userContext12 = mockUserContext('user123', { + gender: 'female', + language: 'en', + country: 'US', + age: '25', + }); + + const variation12 = await cmabService.getDecision(projectConfig, userContext12, '1234', []); + expect(variation11.variationId).toEqual('123'); + expect(variation12.variationId).toEqual('123'); + expect(variation11.cmabUuid).toEqual(variation12.cmabUuid); + + expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(1); + }); + + it('should not mix up the cache between different experiments', async () => { + const mockCmabClient = { + fetchDecision: vi.fn().mockResolvedValueOnce('123') + .mockResolvedValueOnce('456') + .mockResolvedValueOnce('789'), + }; + + const cmabService = new DefaultCmabService({ + cmabCache: getMockSyncCache(), + cmabClient: mockCmabClient, + }); + + const projectConfig = mockProjectConfig(); + const userContext = mockUserContext('user123', { + country: 'US', + age: '25', + }); + + const variation1 = await cmabService.getDecision(projectConfig, userContext, '1234', []); + + const variation2 = await cmabService.getDecision(projectConfig, userContext, '5678', []); + + expect(variation1.variationId).toEqual('123'); + expect(variation2.variationId).toEqual('456'); + expect(variation1.cmabUuid).not.toEqual(variation2.cmabUuid); + }); + + it('should not mix up the cache between different users', async () => { + const mockCmabClient = { + fetchDecision: vi.fn().mockResolvedValueOnce('123') + .mockResolvedValueOnce('456') + .mockResolvedValueOnce('789'), + }; + + const cmabService = new DefaultCmabService({ + cmabCache: getMockSyncCache(), + cmabClient: mockCmabClient, + }); + + const projectConfig = mockProjectConfig(); + + const userContext1 = mockUserContext('user123', { + country: 'US', + age: '25', + }); + + const userContext2 = mockUserContext('user456', { + country: 'US', + age: '25', + }); + + const variation1 = await cmabService.getDecision(projectConfig, userContext1, '1234', []); + + const variation2 = await cmabService.getDecision(projectConfig, userContext2, '1234', []); + expect(variation1.variationId).toEqual('123'); + expect(variation2.variationId).toEqual('456'); + expect(variation1.cmabUuid).not.toEqual(variation2.cmabUuid); + + expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(2); + }); + + it('should invalidate the cache and fetch a new variation if relevant attributes have changed', async () => { + const mockCmabClient = { + fetchDecision: vi.fn().mockResolvedValueOnce('123') + .mockResolvedValueOnce('456'), + }; + + const cmabService = new DefaultCmabService({ + cmabCache: getMockSyncCache(), + cmabClient: mockCmabClient, + }); + + const projectConfig = mockProjectConfig(); + const userContext1 = mockUserContext('user123', { + country: 'US', + age: '25', + language: 'en', + gender: 'male' + }); + + const variation1 = await cmabService.getDecision(projectConfig, userContext1, '1234', []); + + const userContext2 = mockUserContext('user123', { + country: 'US', + age: '50', + language: 'en', + gender: 'male' + }); + + const variation2 = await cmabService.getDecision(projectConfig, userContext2, '1234', []); + expect(variation1.variationId).toEqual('123'); + expect(variation2.variationId).toEqual('456'); + expect(variation1.cmabUuid).not.toEqual(variation2.cmabUuid); + + expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(2); + }); + + it('should ignore the cache and fetch variation if IGNORE_CMAB_CACHE option is provided', async () => { + const mockCmabClient = { + fetchDecision: vi.fn().mockResolvedValueOnce('123') + .mockResolvedValueOnce('456'), + }; + + const cmabService = new DefaultCmabService({ + cmabCache: getMockSyncCache(), + cmabClient: mockCmabClient, + }); + + const projectConfig = mockProjectConfig(); + const userContext = mockUserContext('user123', { + country: 'US', + age: '25', + language: 'en', + gender: 'male' + }); + + const variation1 = await cmabService.getDecision(projectConfig, userContext, '1234', []); + + const variation2 = await cmabService.getDecision(projectConfig, userContext, '1234', [ + OptimizelyDecideOption.IGNORE_CMAB_CACHE, + ]); + + const variation3 = await cmabService.getDecision(projectConfig, userContext, '1234', []); + + expect(variation1.variationId).toEqual('123'); + expect(variation2.variationId).toEqual('456'); + expect(variation1.cmabUuid).not.toEqual(variation2.cmabUuid); + + expect(variation3.variationId).toEqual('123'); + expect(variation3.cmabUuid).toEqual(variation1.cmabUuid); + + expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(2); + }); + + it('should reset the cache before fetching variation if RESET_CMAB_CACHE option is provided', async () => { + const mockCmabClient = { + fetchDecision: vi.fn().mockResolvedValueOnce('123') + .mockResolvedValueOnce('456') + .mockResolvedValueOnce('789') + .mockResolvedValueOnce('101112'), + }; + + const cmabService = new DefaultCmabService({ + cmabCache: getMockSyncCache(), + cmabClient: mockCmabClient, + }); + + const projectConfig = mockProjectConfig(); + const userContext1 = mockUserContext('user123', { + country: 'US', + age: '25' + }); + + const userContext2 = mockUserContext('user456', { + country: 'US', + age: '50' + }); + + const variation1 = await cmabService.getDecision(projectConfig, userContext1, '1234', []); + expect(variation1.variationId).toEqual('123'); + + const variation2 = await cmabService.getDecision(projectConfig, userContext2, '1234', []); + expect(variation2.variationId).toEqual('456'); + + const variation3 = await cmabService.getDecision(projectConfig, userContext1, '1234', [ + OptimizelyDecideOption.RESET_CMAB_CACHE, + ]); + expect(variation3.variationId).toEqual('789'); + + const variation4 = await cmabService.getDecision(projectConfig, userContext2, '1234', []); + expect(variation4.variationId).toEqual('101112'); + }); + + it('should invalidate the cache and fetch a new variation if INVALIDATE_USER_CMAB_CACHE option is provided', async () => { + const mockCmabClient = { + fetchDecision: vi.fn().mockResolvedValueOnce('123') + .mockResolvedValueOnce('456'), + }; + + const cmabService = new DefaultCmabService({ + cmabCache: getMockSyncCache(), + cmabClient: mockCmabClient, + }); + + const projectConfig = mockProjectConfig(); + const userContext = mockUserContext('user123', { + country: 'US', + age: '25', + language: 'en', + gender: 'male' + }); + + const variation1 = await cmabService.getDecision(projectConfig, userContext, '1234', []); + + const variation2 = await cmabService.getDecision(projectConfig, userContext, '1234', [ + OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE, + ]); + + const variation3 = await cmabService.getDecision(projectConfig, userContext, '1234', []); + + expect(variation1.variationId).toEqual('123'); + expect(variation2.variationId).toEqual('456'); + expect(variation1.cmabUuid).not.toEqual(variation2.cmabUuid); + expect(variation3.variationId).toEqual('456'); + expect(variation2.cmabUuid).toEqual(variation3.cmabUuid); + + expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(2); + }); +}); diff --git a/lib/core/decision_service/cmab/cmab_service.ts b/lib/core/decision_service/cmab/cmab_service.ts new file mode 100644 index 000000000..2eaffd4fd --- /dev/null +++ b/lib/core/decision_service/cmab/cmab_service.ts @@ -0,0 +1,156 @@ +/** + * Copyright 2025, Optimizely + * + * 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 + * + * https://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. + */ + +import { LoggerFacade } from "../../../logging/logger"; +import OptimizelyUserContext from "../../../optimizely_user_context" +import { ProjectConfig } from "../../../project_config/project_config" +import { OptimizelyDecideOption, UserAttributes } from "../../../shared_types" +import { Cache } from "../../../utils/cache/cache"; +import { CmabClient } from "./cmab_client"; +import { v4 as uuidV4 } from 'uuid'; +import murmurhash from "murmurhash"; +import { a } from "vitest/dist/chunks/suite.CcK46U-P"; + +export type CmabDecision = { + variationId: string, + cmabUuid: string, +} + +export interface CmabService { + /** + * Get variation id for the user + * @param {OptimizelyUserContext} userContext + * @param {string} ruleId + * @param {OptimizelyDecideOption[]} options + * @return {Promise} + */ + getDecision( + projectConfig: ProjectConfig, + userContext: OptimizelyUserContext, + ruleId: string, + options: OptimizelyDecideOption[] + ): Promise +} + +export type CmabCacheValue = { + attributesHash: string, + variationId: string, + cmabUuid: string, +} + +export type CmabServiceOptions = { + logger?: LoggerFacade; + cmabCache: Cache; + cmabClient: CmabClient; +} + +export class DefaultCmabService implements CmabService { + private cmabCache: Cache; + private cmabClient: CmabClient; + private logger?: LoggerFacade; + + constructor(options: CmabServiceOptions) { + this.cmabCache = options.cmabCache; + this.cmabClient = options.cmabClient; + this.logger = options.logger; + } + + async getDecision( + projectConfig: ProjectConfig, + userContext: OptimizelyUserContext, + ruleId: string, + options: OptimizelyDecideOption[] + ): Promise { + const filteredAttributes = this.filterAttributes(projectConfig, userContext, ruleId); + + if (options.includes(OptimizelyDecideOption.IGNORE_CMAB_CACHE)) { + return this.fetchDecision(ruleId, userContext.getUserId(), filteredAttributes); + } + + if (options.includes(OptimizelyDecideOption.RESET_CMAB_CACHE)) { + this.cmabCache.clear(); + } + + const cacheKey = this.getCacheKey(userContext.getUserId(), ruleId); + + if (options.includes(OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE)) { + this.cmabCache.remove(cacheKey); + } + + const cachedValue = await this.cmabCache.get(cacheKey); + + const attributesJson = JSON.stringify(filteredAttributes, Object.keys(filteredAttributes).sort()); + const attributesHash = String(murmurhash.v3(attributesJson)); + + if (cachedValue) { + if (cachedValue.attributesHash === attributesHash) { + return { variationId: cachedValue.variationId, cmabUuid: cachedValue.cmabUuid }; + } else { + this.cmabCache.remove(cacheKey); + } + } + + const variation = await this.fetchDecision(ruleId, userContext.getUserId(), filteredAttributes); + this.cmabCache.set(cacheKey, { + attributesHash, + variationId: variation.variationId, + cmabUuid: variation.cmabUuid, + }); + + return variation; + } + + private async fetchDecision( + ruleId: string, + userId: string, + attributes: UserAttributes, + ): Promise { + const cmabUuid = uuidV4(); + const variationId = await this.cmabClient.fetchDecision(ruleId, userId, attributes, cmabUuid); + return { variationId, cmabUuid }; + } + + private filterAttributes( + projectConfig: ProjectConfig, + userContext: OptimizelyUserContext, + ruleId: string + ): UserAttributes { + const filteredAttributes: UserAttributes = {}; + const userAttributes = userContext.getAttributes(); + + const experiment = projectConfig.experimentIdMap[ruleId]; + if (!experiment || !experiment.cmab) { + return filteredAttributes; + } + + const cmabAttributeIds = experiment.cmab.attributeIds; + + cmabAttributeIds.forEach((aid) => { + const attribute = projectConfig.attributeIdMap[aid]; + + if (userAttributes.hasOwnProperty(attribute.key)) { + filteredAttributes[attribute.key] = userAttributes[attribute.key]; + } + }); + + return filteredAttributes; + } + + private getCacheKey(userId: string, ruleId: string): string { + const len = userId.length; + return `${len}-${userId}-${ruleId}`; + } +} diff --git a/lib/project_config/project_config.ts b/lib/project_config/project_config.ts index 756c8c058..92b9c1ac5 100644 --- a/lib/project_config/project_config.ts +++ b/lib/project_config/project_config.ts @@ -88,6 +88,7 @@ export interface ProjectConfig { eventKeyMap: { [key: string]: Event }; audiences: Audience[]; attributeKeyMap: { [key: string]: { id: string } }; + attributeIdMap: { [id: string]: { key: string } }; variationIdMap: { [id: string]: OptimizelyVariation }; variationVariableUsageMap: { [id: string]: VariableUsageMap }; audiencesById: { [id: string]: Audience }; @@ -178,7 +179,14 @@ export const createProjectConfig = function(datafileObj?: JSON, datafileStr: str ...keyBy(projectConfig.typedAudiences, 'id'), } - projectConfig.attributeKeyMap = keyBy(projectConfig.attributes, 'key'); + projectConfig.attributes = projectConfig.attributes || []; + projectConfig.attributeKeyMap = {}; + projectConfig.attributeIdMap = {}; + projectConfig.attributes.forEach(attribute => { + projectConfig.attributeKeyMap[attribute.key] = attribute; + projectConfig.attributeIdMap[attribute.id] = attribute; + }); + projectConfig.eventKeyMap = keyBy(projectConfig.events, 'key'); projectConfig.groupIdMap = keyBy(projectConfig.groups, 'id'); diff --git a/lib/shared_types.ts b/lib/shared_types.ts index 870b55ddc..4db7c0da1 100644 --- a/lib/shared_types.ts +++ b/lib/shared_types.ts @@ -252,6 +252,9 @@ export enum 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', } /**