From 54e9e8609136349bb98f9bb4a54ba8aadcae4c00 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Wed, 26 Feb 2025 14:44:36 -0300 Subject: [PATCH 1/3] Add RBSegmentsCache interface and implementations --- src/dtos/types.ts | 11 ++ src/storages/AbstractSplitsCacheSync.ts | 4 +- src/storages/KeyBuilder.ts | 12 ++ src/storages/KeyBuilderCS.ts | 6 +- src/storages/KeyBuilderSS.ts | 4 + .../inLocalStorage/RBSegmentsCacheInLocal.ts | 144 ++++++++++++++++++ src/storages/inLocalStorage/index.ts | 4 + src/storages/inMemory/InMemoryStorage.ts | 3 + src/storages/inMemory/InMemoryStorageCS.ts | 4 + .../inMemory/RBSegmentsCacheInMemory.ts | 68 +++++++++ .../inRedis/RBSegmentsCacheInRedis.ts | 79 ++++++++++ src/storages/inRedis/index.ts | 2 + .../pluggable/RBSegmentsCachePluggable.ts | 76 +++++++++ src/storages/pluggable/index.ts | 2 + src/storages/types.ts | 34 ++++- 15 files changed, 449 insertions(+), 4 deletions(-) create mode 100644 src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts create mode 100644 src/storages/inMemory/RBSegmentsCacheInMemory.ts create mode 100644 src/storages/inRedis/RBSegmentsCacheInRedis.ts create mode 100644 src/storages/pluggable/RBSegmentsCachePluggable.ts diff --git a/src/dtos/types.ts b/src/dtos/types.ts index 7573183c..1bb49a21 100644 --- a/src/dtos/types.ts +++ b/src/dtos/types.ts @@ -194,6 +194,17 @@ export interface ISplitCondition { conditionType: 'ROLLOUT' | 'WHITELIST' } +export interface IRBSegment { + name: string, + changeNumber: number, + status: 'ACTIVE' | 'ARCHIVED', + excluded: { + keys: string[], + segments: string[] + }, + conditions: ISplitCondition[], +} + export interface ISplit { name: string, changeNumber: number, diff --git a/src/storages/AbstractSplitsCacheSync.ts b/src/storages/AbstractSplitsCacheSync.ts index 512d990e..b03dbc7d 100644 --- a/src/storages/AbstractSplitsCacheSync.ts +++ b/src/storages/AbstractSplitsCacheSync.ts @@ -1,5 +1,5 @@ import { ISplitsCacheSync } from './types'; -import { ISplit } from '../dtos/types'; +import { IRBSegment, ISplit } from '../dtos/types'; import { objectAssign } from '../utils/lang/objectAssign'; import { IN_SEGMENT, IN_LARGE_SEGMENT } from '../utils/constants'; @@ -80,7 +80,7 @@ export abstract class AbstractSplitsCacheSync implements ISplitsCacheSync { * Given a parsed split, it returns a boolean flagging if its conditions use segments matchers (rules & whitelists). * This util is intended to simplify the implementation of `splitsCache::usesSegments` method */ -export function usesSegments(split: ISplit) { +export function usesSegments(split: ISplit | IRBSegment) { const conditions = split.conditions || []; for (let i = 0; i < conditions.length; i++) { const matchers = conditions[i].matcherGroup.matchers; diff --git a/src/storages/KeyBuilder.ts b/src/storages/KeyBuilder.ts index 2f5dc800..dfd42f18 100644 --- a/src/storages/KeyBuilder.ts +++ b/src/storages/KeyBuilder.ts @@ -37,6 +37,18 @@ export class KeyBuilder { return `${this.prefix}.split.`; } + buildRBSegmentKey(splitName: string) { + return `${this.prefix}.rbsegment.${splitName}`; + } + + buildRBSegmentTillKey() { + return `${this.prefix}.rbsegments.till`; + } + + buildRBSegmentKeyPrefix() { + return `${this.prefix}.rbsegment.`; + } + buildSegmentNameKey(segmentName: string) { return `${this.prefix}.segment.${segmentName}`; } diff --git a/src/storages/KeyBuilderCS.ts b/src/storages/KeyBuilderCS.ts index 23961f89..3ca446ba 100644 --- a/src/storages/KeyBuilderCS.ts +++ b/src/storages/KeyBuilderCS.ts @@ -15,7 +15,7 @@ export class KeyBuilderCS extends KeyBuilder implements MySegmentsKeyBuilder { constructor(prefix: string, matchingKey: string) { super(prefix); this.matchingKey = matchingKey; - this.regexSplitsCacheKey = new RegExp(`^${prefix}\\.(splits?|trafficType|flagSet)\\.`); + this.regexSplitsCacheKey = new RegExp(`^${prefix}\\.(splits?|trafficType|flagSet|rbsegment)\\.`); } /** @@ -47,6 +47,10 @@ export class KeyBuilderCS extends KeyBuilder implements MySegmentsKeyBuilder { return startsWith(key, `${this.prefix}.split.`); } + isRBSegmentKey(key: string) { + return startsWith(key, `${this.prefix}.rbsegment.`); + } + buildSplitsWithSegmentCountKey() { return `${this.prefix}.splits.usingSegments`; } diff --git a/src/storages/KeyBuilderSS.ts b/src/storages/KeyBuilderSS.ts index 6232d88a..cf8d2156 100644 --- a/src/storages/KeyBuilderSS.ts +++ b/src/storages/KeyBuilderSS.ts @@ -53,6 +53,10 @@ export class KeyBuilderSS extends KeyBuilder { return `${this.buildSplitKeyPrefix()}*`; } + searchPatternForRBSegmentKeys() { + return `${this.buildRBSegmentKeyPrefix()}*`; + } + /* Telemetry keys */ buildLatencyKey(method: Method, bucket: number) { diff --git a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts new file mode 100644 index 00000000..7c332543 --- /dev/null +++ b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts @@ -0,0 +1,144 @@ +import { IRBSegment } from '../../dtos/types'; +import { ILogger } from '../../logger/types'; +import { ISettings } from '../../types'; +import { isFiniteNumber, isNaNNumber, toNumber } from '../../utils/lang'; +import { setToArray } from '../../utils/lang/sets'; +import { usesSegments } from '../AbstractSplitsCacheSync'; +import { KeyBuilderCS } from '../KeyBuilderCS'; +import { IRBSegmentsCacheSync } from '../types'; +import { LOG_PREFIX } from './constants'; + +export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { + + private readonly keys: KeyBuilderCS; + private readonly log: ILogger; + private hasSync?: boolean; + + constructor(settings: ISettings, keys: KeyBuilderCS) { + this.keys = keys; + this.log = settings.log; + } + + clear() { + this.hasSync = false; + // SplitsCacheInLocal.clear() does the rest of the job + } + + update(toAdd: IRBSegment[], toRemove: IRBSegment[], changeNumber: number): boolean { + this.setChangeNumber(changeNumber); + const updated = toAdd.map(toAdd => this.add(toAdd)).some(result => result); + return toRemove.map(toRemove => this.remove(toRemove.name)).some(result => result) || updated; + } + + private setChangeNumber(changeNumber: number) { + try { + localStorage.setItem(this.keys.buildSplitsTillKey(), changeNumber + ''); + localStorage.setItem(this.keys.buildLastUpdatedKey(), Date.now() + ''); + this.hasSync = true; + } catch (e) { + this.log.error(LOG_PREFIX + e); + } + } + + private updateSegmentCount(diff: number){ + const segmentsCountKey = this.keys.buildSplitsWithSegmentCountKey(); + const count = toNumber(localStorage.getItem(segmentsCountKey)) - diff; + // @ts-expect-error + if (count > 0) localStorage.setItem(segmentsCountKey, count); + else localStorage.removeItem(segmentsCountKey); + } + + private add(rbSegment: IRBSegment): boolean { + try { + const name = rbSegment.name; + const rbSegmentKey = this.keys.buildRBSegmentKey(name); + const rbSegmentFromLocalStorage = localStorage.getItem(rbSegmentKey); + const previous = rbSegmentFromLocalStorage ? JSON.parse(rbSegmentFromLocalStorage) : null; + + localStorage.setItem(rbSegmentKey, JSON.stringify(rbSegment)); + + let usesSegmentsDiff = 0; + if (previous && usesSegments(previous)) usesSegmentsDiff--; + if (usesSegments(rbSegment)) usesSegmentsDiff++; + if (usesSegmentsDiff !== 0) this.updateSegmentCount(usesSegmentsDiff); + + return true; + } catch (e) { + this.log.error(LOG_PREFIX + e); + return false; + } + } + + private remove(name: string): boolean { + try { + const rbSegment = this.get(name); + if (!rbSegment) return false; + + localStorage.removeItem(this.keys.buildRBSegmentKey(name)); + + if (usesSegments(rbSegment)) this.updateSegmentCount(-1); + + return true; + + } catch (e) { + this.log.error(LOG_PREFIX + e); + return false; + } + } + + private getNames(): string[] { + const len = localStorage.length; + const accum = []; + + let cur = 0; + + while (cur < len) { + const key = localStorage.key(cur); + + if (key != null && this.keys.isRBSegmentKey(key)) accum.push(this.keys.extractKey(key)); + + cur++; + } + + return accum; + } + + get(name: string): IRBSegment | null { + const item = localStorage.getItem(this.keys.buildRBSegmentKey(name)); + return item && JSON.parse(item); + } + + contains(names: Set): boolean { + const namesArray = setToArray(names); + const namesInStorage = this.getNames(); + return namesArray.every(name => namesInStorage.indexOf(name) !== -1); + } + + getChangeNumber(): number { + const n = -1; + let value: string | number | null = localStorage.getItem(this.keys.buildRBSegmentTillKey()); + + if (value !== null) { + value = parseInt(value, 10); + + return isNaNNumber(value) ? n : value; + } + + return n; + } + + usesSegments(): boolean { + // If cache hasn't been synchronized, assume we need segments + if (!this.hasSync) return true; + + const storedCount = localStorage.getItem(this.keys.buildSplitsWithSegmentCountKey()); + const splitsWithSegmentsCount = storedCount === null ? 0 : toNumber(storedCount); + + if (isFiniteNumber(splitsWithSegmentsCount)) { + return splitsWithSegmentsCount > 0; + } else { + return true; + } + } + +} diff --git a/src/storages/inLocalStorage/index.ts b/src/storages/inLocalStorage/index.ts index c621141d..83136487 100644 --- a/src/storages/inLocalStorage/index.ts +++ b/src/storages/inLocalStorage/index.ts @@ -14,6 +14,7 @@ import { STORAGE_LOCALSTORAGE } from '../../utils/constants'; import { shouldRecordTelemetry, TelemetryCacheInMemory } from '../inMemory/TelemetryCacheInMemory'; import { UniqueKeysCacheInMemoryCS } from '../inMemory/UniqueKeysCacheInMemoryCS'; import { getMatching } from '../../utils/key'; +import { RBSegmentsCacheInLocal } from './RBSegmentsCacheInLocal'; export interface InLocalStorageOptions { prefix?: string @@ -40,11 +41,13 @@ export function InLocalStorage(options: InLocalStorageOptions = {}): IStorageSyn const expirationTimestamp = Date.now() - DEFAULT_CACHE_EXPIRATION_IN_MILLIS; const splits = new SplitsCacheInLocal(settings, keys, expirationTimestamp); + const rbSegments = new RBSegmentsCacheInLocal(settings, keys); const segments = new MySegmentsCacheInLocal(log, keys); const largeSegments = new MySegmentsCacheInLocal(log, myLargeSegmentsKeyBuilder(prefix, matchingKey)); return { splits, + rbSegments, segments, largeSegments, impressions: new ImpressionsCacheInMemory(impressionsQueueSize), @@ -60,6 +63,7 @@ export function InLocalStorage(options: InLocalStorageOptions = {}): IStorageSyn return { splits: this.splits, + rbSegments: this.rbSegments, segments: new MySegmentsCacheInLocal(log, new KeyBuilderCS(prefix, matchingKey)), largeSegments: new MySegmentsCacheInLocal(log, myLargeSegmentsKeyBuilder(prefix, matchingKey)), impressions: this.impressions, diff --git a/src/storages/inMemory/InMemoryStorage.ts b/src/storages/inMemory/InMemoryStorage.ts index 7ec099d1..e89a875d 100644 --- a/src/storages/inMemory/InMemoryStorage.ts +++ b/src/storages/inMemory/InMemoryStorage.ts @@ -7,6 +7,7 @@ import { ImpressionCountsCacheInMemory } from './ImpressionCountsCacheInMemory'; import { LOCALHOST_MODE, STORAGE_MEMORY } from '../../utils/constants'; import { shouldRecordTelemetry, TelemetryCacheInMemory } from './TelemetryCacheInMemory'; import { UniqueKeysCacheInMemory } from './UniqueKeysCacheInMemory'; +import { RBSegmentsCacheInMemory } from './RBSegmentsCacheInMemory'; /** * InMemory storage factory for standalone server-side SplitFactory @@ -17,10 +18,12 @@ export function InMemoryStorageFactory(params: IStorageFactoryParams): IStorageS const { settings: { scheduler: { impressionsQueueSize, eventsQueueSize, }, sync: { __splitFiltersValidation } } } = params; const splits = new SplitsCacheInMemory(__splitFiltersValidation); + const rbSegments = new RBSegmentsCacheInMemory(); const segments = new SegmentsCacheInMemory(); const storage = { splits, + rbSegments, segments, impressions: new ImpressionsCacheInMemory(impressionsQueueSize), impressionCounts: new ImpressionCountsCacheInMemory(), diff --git a/src/storages/inMemory/InMemoryStorageCS.ts b/src/storages/inMemory/InMemoryStorageCS.ts index bfaec159..5ae8351c 100644 --- a/src/storages/inMemory/InMemoryStorageCS.ts +++ b/src/storages/inMemory/InMemoryStorageCS.ts @@ -7,6 +7,7 @@ import { ImpressionCountsCacheInMemory } from './ImpressionCountsCacheInMemory'; import { LOCALHOST_MODE, STORAGE_MEMORY } from '../../utils/constants'; import { shouldRecordTelemetry, TelemetryCacheInMemory } from './TelemetryCacheInMemory'; import { UniqueKeysCacheInMemoryCS } from './UniqueKeysCacheInMemoryCS'; +import { RBSegmentsCacheInMemory } from './RBSegmentsCacheInMemory'; /** * InMemory storage factory for standalone client-side SplitFactory @@ -17,11 +18,13 @@ export function InMemoryStorageCSFactory(params: IStorageFactoryParams): IStorag const { settings: { scheduler: { impressionsQueueSize, eventsQueueSize }, sync: { __splitFiltersValidation } } } = params; const splits = new SplitsCacheInMemory(__splitFiltersValidation); + const rbSegments = new RBSegmentsCacheInMemory(); const segments = new MySegmentsCacheInMemory(); const largeSegments = new MySegmentsCacheInMemory(); const storage = { splits, + rbSegments, segments, largeSegments, impressions: new ImpressionsCacheInMemory(impressionsQueueSize), @@ -36,6 +39,7 @@ export function InMemoryStorageCSFactory(params: IStorageFactoryParams): IStorag shared() { return { splits: this.splits, + rbSegments: this.rbSegments, segments: new MySegmentsCacheInMemory(), largeSegments: new MySegmentsCacheInMemory(), impressions: this.impressions, diff --git a/src/storages/inMemory/RBSegmentsCacheInMemory.ts b/src/storages/inMemory/RBSegmentsCacheInMemory.ts new file mode 100644 index 00000000..78debb86 --- /dev/null +++ b/src/storages/inMemory/RBSegmentsCacheInMemory.ts @@ -0,0 +1,68 @@ +import { IRBSegment } from '../../dtos/types'; +import { setToArray } from '../../utils/lang/sets'; +import { usesSegments } from '../AbstractSplitsCacheSync'; +import { IRBSegmentsCacheSync } from '../types'; + +export class RBSegmentsCacheInMemory implements IRBSegmentsCacheSync { + + private cache: Record = {}; + private changeNumber: number = -1; + private segmentsCount: number = 0; + + clear() { + this.cache = {}; + this.changeNumber = -1; + this.segmentsCount = 0; + } + + update(toAdd: IRBSegment[], toRemove: IRBSegment[], changeNumber: number): boolean { + this.changeNumber = changeNumber; + const updated = toAdd.map(toAdd => this.add(toAdd)).some(result => result); + return toRemove.map(toRemove => this.remove(toRemove.name)).some(result => result) || updated; + } + + private add(rbSegment: IRBSegment): boolean { + const name = rbSegment.name; + const previous = this.get(name); + if (previous && usesSegments(previous)) this.segmentsCount--; + + this.cache[name] = rbSegment; + if (usesSegments(rbSegment)) this.segmentsCount++; + + return true; + } + + private remove(name: string): boolean { + const rbSegment = this.get(name); + if (!rbSegment) return false; + + delete this.cache[name]; + + if (usesSegments(rbSegment)) this.segmentsCount--; + + return true; + } + + private getNames(): string[] { + return Object.keys(this.cache); + } + + get(name: string): IRBSegment | null { + return this.cache[name] || null; + } + + contains(names: Set): boolean { + const namesArray = setToArray(names); + const namesInStorage = this.getNames(); + return namesArray.every(name => namesInStorage.indexOf(name) !== -1); + } + + getChangeNumber(): number { + return this.changeNumber; + } + + usesSegments(): boolean { + return this.getChangeNumber() === -1 || this.segmentsCount > 0; + } + +} diff --git a/src/storages/inRedis/RBSegmentsCacheInRedis.ts b/src/storages/inRedis/RBSegmentsCacheInRedis.ts new file mode 100644 index 00000000..8e369d62 --- /dev/null +++ b/src/storages/inRedis/RBSegmentsCacheInRedis.ts @@ -0,0 +1,79 @@ +import { isNaNNumber } from '../../utils/lang'; +import { IRBSegmentsCacheAsync } from '../types'; +import { ILogger } from '../../logger/types'; +import { IRBSegment } from '../../dtos/types'; +import { LOG_PREFIX } from './constants'; +import { setToArray } from '../../utils/lang/sets'; +import { RedisAdapter } from './RedisAdapter'; +import { KeyBuilderSS } from '../KeyBuilderSS'; + +export class RBSegmentsCacheInRedis implements IRBSegmentsCacheAsync { + + private readonly log: ILogger; + private readonly keys: KeyBuilderSS; + private readonly redis: RedisAdapter; + + constructor(log: ILogger, keys: KeyBuilderSS, redis: RedisAdapter) { + this.log = log; + this.keys = keys; + this.redis = redis; + } + + get(name: string): Promise { + return this.redis.get(this.keys.buildRBSegmentKey(name)) + .then(maybeRBSegment => maybeRBSegment && JSON.parse(maybeRBSegment)); + } + + private getNames(): Promise { + return this.redis.keys(this.keys.searchPatternForRBSegmentKeys()).then( + (listOfKeys) => listOfKeys.map(this.keys.extractKey) + ); + } + + contains(names: Set): Promise { + const namesArray = setToArray(names); + return this.getNames().then(namesInStorage => { + return namesArray.every(name => namesInStorage.includes(name)); + }); + } + + update(toAdd: IRBSegment[], toRemove: IRBSegment[], changeNumber: number): Promise { + return Promise.all([ + this.setChangeNumber(changeNumber), + Promise.all(toAdd.map(toAdd => { + const key = this.keys.buildRBSegmentKey(toAdd.name); + const stringifiedNewRBSegment = JSON.stringify(toAdd); + return this.redis.set(key, stringifiedNewRBSegment).then(() => true); + })), + Promise.all(toRemove.map(toRemove => { + const key = this.keys.buildRBSegmentKey(toRemove.name); + return this.redis.del(key).then(status => status === 1); + })) + ]).then(([, added, removed]) => { + return added.some(result => result) || removed.some(result => result); + }); + } + + setChangeNumber(changeNumber: number) { + return this.redis.set(this.keys.buildRBSegmentTillKey(), changeNumber + '').then( + status => status === 'OK' + ); + } + + getChangeNumber(): Promise { + return this.redis.get(this.keys.buildRBSegmentTillKey()).then((value: string | null) => { + const i = parseInt(value as string, 10); + + return isNaNNumber(i) ? -1 : i; + }).catch((e) => { + this.log.error(LOG_PREFIX + 'Could not retrieve changeNumber from storage. Error: ' + e); + return -1; + }); + } + + // @TODO implement if required by DataLoader or producer mode + clear() { + return Promise.resolve(true); + } + +} diff --git a/src/storages/inRedis/index.ts b/src/storages/inRedis/index.ts index e548142d..2d66a1aa 100644 --- a/src/storages/inRedis/index.ts +++ b/src/storages/inRedis/index.ts @@ -11,6 +11,7 @@ import { TelemetryCacheInRedis } from './TelemetryCacheInRedis'; import { UniqueKeysCacheInRedis } from './UniqueKeysCacheInRedis'; import { ImpressionCountsCacheInRedis } from './ImpressionCountsCacheInRedis'; import { metadataBuilder } from '../utils'; +import { RBSegmentsCacheInRedis } from './RBSegmentsCacheInRedis'; export interface InRedisStorageOptions { prefix?: string @@ -50,6 +51,7 @@ export function InRedisStorage(options: InRedisStorageOptions = {}): IStorageAsy return { splits: new SplitsCacheInRedis(log, keys, redisClient, settings.sync.__splitFiltersValidation), + rbSegments: new RBSegmentsCacheInRedis(log, keys, redisClient), segments: new SegmentsCacheInRedis(log, keys, redisClient), impressions: new ImpressionsCacheInRedis(log, keys.buildImpressionsKey(), redisClient, metadata), impressionCounts: impressionCountsCache, diff --git a/src/storages/pluggable/RBSegmentsCachePluggable.ts b/src/storages/pluggable/RBSegmentsCachePluggable.ts new file mode 100644 index 00000000..294e34d8 --- /dev/null +++ b/src/storages/pluggable/RBSegmentsCachePluggable.ts @@ -0,0 +1,76 @@ +import { isNaNNumber } from '../../utils/lang'; +import { KeyBuilder } from '../KeyBuilder'; +import { IPluggableStorageWrapper, IRBSegmentsCacheAsync } from '../types'; +import { ILogger } from '../../logger/types'; +import { IRBSegment } from '../../dtos/types'; +import { LOG_PREFIX } from './constants'; +import { setToArray } from '../../utils/lang/sets'; + +export class RBSegmentsCachePluggable implements IRBSegmentsCacheAsync { + + private readonly log: ILogger; + private readonly keys: KeyBuilder; + private readonly wrapper: IPluggableStorageWrapper; + + constructor(log: ILogger, keys: KeyBuilder, wrapper: IPluggableStorageWrapper) { + this.log = log; + this.keys = keys; + this.wrapper = wrapper; + } + + get(name: string): Promise { + return this.wrapper.get(this.keys.buildRBSegmentKey(name)) + .then(maybeRBSegment => maybeRBSegment && JSON.parse(maybeRBSegment)); + } + + private getNames(): Promise { + return this.wrapper.getKeysByPrefix(this.keys.buildRBSegmentKeyPrefix()).then( + (listOfKeys) => listOfKeys.map(this.keys.extractKey) + ); + } + + contains(names: Set): Promise { + const namesArray = setToArray(names); + return this.getNames().then(namesInStorage => { + return namesArray.every(name => namesInStorage.includes(name)); + }); + } + + update(toAdd: IRBSegment[], toRemove: IRBSegment[], changeNumber: number): Promise { + return Promise.all([ + this.setChangeNumber(changeNumber), + Promise.all(toAdd.map(toAdd => { + const key = this.keys.buildRBSegmentKey(toAdd.name); + const stringifiedNewRBSegment = JSON.stringify(toAdd); + return this.wrapper.set(key, stringifiedNewRBSegment).then(() => true); + })), + Promise.all(toRemove.map(toRemove => { + const key = this.keys.buildRBSegmentKey(toRemove.name); + return this.wrapper.del(key); + })) + ]).then(([, added, removed]) => { + return added.some(result => result) || removed.some(result => result); + }); + } + + setChangeNumber(changeNumber: number) { + return this.wrapper.set(this.keys.buildRBSegmentTillKey(), changeNumber + ''); + } + + getChangeNumber(): Promise { + return this.wrapper.get(this.keys.buildRBSegmentTillKey()).then((value) => { + const i = parseInt(value as string, 10); + + return isNaNNumber(i) ? -1 : i; + }).catch((e) => { + this.log.error(LOG_PREFIX + 'Could not retrieve changeNumber from storage. Error: ' + e); + return -1; + }); + } + + // @TODO implement if required by DataLoader or producer mode + clear() { + return Promise.resolve(true); + } + +} diff --git a/src/storages/pluggable/index.ts b/src/storages/pluggable/index.ts index ee8b1872..f29e4ec4 100644 --- a/src/storages/pluggable/index.ts +++ b/src/storages/pluggable/index.ts @@ -20,6 +20,7 @@ import { UniqueKeysCacheInMemory } from '../inMemory/UniqueKeysCacheInMemory'; import { UniqueKeysCacheInMemoryCS } from '../inMemory/UniqueKeysCacheInMemoryCS'; import { metadataBuilder } from '../utils'; import { LOG_PREFIX } from '../pluggable/constants'; +import { RBSegmentsCachePluggable } from './RBSegmentsCachePluggable'; const NO_VALID_WRAPPER = 'Expecting pluggable storage `wrapper` in options, but no valid wrapper instance was provided.'; const NO_VALID_WRAPPER_INTERFACE = 'The provided wrapper instance doesn’t follow the expected interface. Check our docs.'; @@ -116,6 +117,7 @@ export function PluggableStorage(options: PluggableStorageOptions): IStorageAsyn return { splits: new SplitsCachePluggable(log, keys, wrapper, settings.sync.__splitFiltersValidation), + rbSegments: new RBSegmentsCachePluggable(log, keys, wrapper), segments: new SegmentsCachePluggable(log, keys, wrapper), impressions: isPartialConsumer ? new ImpressionsCacheInMemory(impressionsQueueSize) : new ImpressionsCachePluggable(log, keys.buildImpressionsKey(), wrapper, metadata), impressionCounts: impressionCountsCache, diff --git a/src/storages/types.ts b/src/storages/types.ts index 71962254..bfc63f57 100644 --- a/src/storages/types.ts +++ b/src/storages/types.ts @@ -1,5 +1,5 @@ import SplitIO from '../../types/splitio'; -import { MaybeThenable, ISplit, IMySegmentsResponse } from '../dtos/types'; +import { MaybeThenable, ISplit, IRBSegment, IMySegmentsResponse } from '../dtos/types'; import { MySegmentsData } from '../sync/polling/types'; import { EventDataType, HttpErrors, HttpLatencies, ImpressionDataType, LastSync, Method, MethodExceptions, MethodLatencies, MultiMethodExceptions, MultiMethodLatencies, MultiConfigs, OperationType, StoredEventWithMetadata, StoredImpressionWithMetadata, StreamingEvent, UniqueKeysPayloadCs, UniqueKeysPayloadSs, TelemetryUsageStatsPayload, UpdatesFromSSEEnum } from '../sync/submitters/types'; import { ISettings } from '../types'; @@ -225,6 +225,34 @@ export interface ISplitsCacheAsync extends ISplitsCacheBase { getNamesByFlagSets(flagSets: string[]): Promise[]> } +/** Rule-Based Segments cache */ + +export interface IRBSegmentsCacheBase { + update(toAdd: IRBSegment[], toRemove: IRBSegment[], changeNumber: number): MaybeThenable, + get(name: string): MaybeThenable, + getChangeNumber(): MaybeThenable, + clear(): MaybeThenable, + contains(names: Set): MaybeThenable, +} + +export interface IRBSegmentsCacheSync extends IRBSegmentsCacheBase { + update(toAdd: IRBSegment[], toRemove: IRBSegment[], changeNumber: number): boolean, + get(name: string): IRBSegment | null, + getChangeNumber(): number, + clear(): void, + contains(names: Set): boolean, + // Used only for smart pausing in client-side standalone. Returns true if the storage contains a RBSegment using segments or large segments matchers + usesSegments(): boolean, +} + +export interface IRBSegmentsCacheAsync extends IRBSegmentsCacheBase { + update(toAdd: IRBSegment[], toRemove: IRBSegment[], changeNumber: number): Promise, + get(name: string): Promise, + getChangeNumber(): Promise, + clear(): Promise, + contains(names: Set): Promise, +} + /** Segments cache */ export interface ISegmentsCacheBase { @@ -423,6 +451,7 @@ export interface ITelemetryCacheAsync extends ITelemetryEvaluationProducerAsync, export interface IStorageBase< TSplitsCache extends ISplitsCacheBase = ISplitsCacheBase, + TRBSegmentsCache extends IRBSegmentsCacheBase = IRBSegmentsCacheBase, TSegmentsCache extends ISegmentsCacheBase = ISegmentsCacheBase, TImpressionsCache extends IImpressionsCacheBase = IImpressionsCacheBase, TImpressionsCountCache extends IImpressionCountsCacheBase = IImpressionCountsCacheBase, @@ -431,6 +460,7 @@ export interface IStorageBase< TUniqueKeysCache extends IUniqueKeysCacheBase = IUniqueKeysCacheBase > { splits: TSplitsCache, + rbSegments: TRBSegmentsCache, segments: TSegmentsCache, impressions: TImpressionsCache, impressionCounts: TImpressionsCountCache, @@ -443,6 +473,7 @@ export interface IStorageBase< export interface IStorageSync extends IStorageBase< ISplitsCacheSync, + IRBSegmentsCacheSync, ISegmentsCacheSync, IImpressionsCacheSync, IImpressionCountsCacheSync, @@ -456,6 +487,7 @@ export interface IStorageSync extends IStorageBase< export interface IStorageAsync extends IStorageBase< ISplitsCacheAsync, + IRBSegmentsCacheAsync, ISegmentsCacheAsync, IImpressionsCacheAsync | IImpressionsCacheSync, IImpressionCountsCacheBase, From a57b082123c9bd914b4b16e0a01629ef5e7f0926 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Wed, 26 Feb 2025 17:10:37 -0300 Subject: [PATCH 2/3] Unit tests --- src/storages/KeyBuilder.ts | 2 +- src/storages/__tests__/testUtils.ts | 8 ++- .../inLocalStorage/RBSegmentsCacheInLocal.ts | 10 +-- .../inLocalStorage/SplitsCacheInLocal.ts | 16 ++--- .../__tests__/RBSegmentsCacheSync.spec.ts | 64 +++++++++++++++++++ 5 files changed, 84 insertions(+), 16 deletions(-) create mode 100644 src/storages/inMemory/__tests__/RBSegmentsCacheSync.spec.ts diff --git a/src/storages/KeyBuilder.ts b/src/storages/KeyBuilder.ts index dfd42f18..7e9b7e85 100644 --- a/src/storages/KeyBuilder.ts +++ b/src/storages/KeyBuilder.ts @@ -41,7 +41,7 @@ export class KeyBuilder { return `${this.prefix}.rbsegment.${splitName}`; } - buildRBSegmentTillKey() { + buildRBSegmentsTillKey() { return `${this.prefix}.rbsegments.till`; } diff --git a/src/storages/__tests__/testUtils.ts b/src/storages/__tests__/testUtils.ts index fa38944f..a4009b1c 100644 --- a/src/storages/__tests__/testUtils.ts +++ b/src/storages/__tests__/testUtils.ts @@ -1,4 +1,4 @@ -import { ISplit } from '../../dtos/types'; +import { IRBSegment, ISplit } from '../../dtos/types'; import { IStorageSync, IStorageAsync, IImpressionsCacheSync, IEventsCacheSync } from '../types'; // Assert that instances created by storage factories have the expected interface @@ -45,3 +45,9 @@ export const featureFlagTwo: ISplit = { name: 'ff_two', sets: ['t','w','o'] }; export const featureFlagThree: ISplit = { name: 'ff_three', sets: ['t','h','r','e'] }; //@ts-ignore export const featureFlagWithoutFS: ISplit = { name: 'ff_four' }; + +// Rule-based segments +//@ts-ignore +export const rbSegment: IRBSegment = { name: 'rb_segment', conditions: [{ matcherGroup: { matchers: [{ matcherType: 'EQUAL_TO', unaryNumericMatcherData: { value: 10 } }] } }] }; +//@ts-ignore +export const rbSegmentWithInSegmentMatcher: IRBSegment = { name: 'rb_segment_with_in_segment_matcher', conditions: [{ matcherGroup: { matchers: [{ matcherType: 'IN_SEGMENT', userDefinedSegmentMatcherData: { segmentName: 'employees' } }] } }] }; diff --git a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts index 7c332543..28c0d1ee 100644 --- a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts @@ -20,8 +20,9 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { } clear() { + this.getNames().forEach(name => this.remove(name)); + localStorage.removeItem(this.keys.buildRBSegmentsTillKey()); this.hasSync = false; - // SplitsCacheInLocal.clear() does the rest of the job } update(toAdd: IRBSegment[], toRemove: IRBSegment[], changeNumber: number): boolean { @@ -32,7 +33,7 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { private setChangeNumber(changeNumber: number) { try { - localStorage.setItem(this.keys.buildSplitsTillKey(), changeNumber + ''); + localStorage.setItem(this.keys.buildRBSegmentsTillKey(), changeNumber + ''); localStorage.setItem(this.keys.buildLastUpdatedKey(), Date.now() + ''); this.hasSync = true; } catch (e) { @@ -42,7 +43,7 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { private updateSegmentCount(diff: number){ const segmentsCountKey = this.keys.buildSplitsWithSegmentCountKey(); - const count = toNumber(localStorage.getItem(segmentsCountKey)) - diff; + const count = toNumber(localStorage.getItem(segmentsCountKey)) + diff; // @ts-expect-error if (count > 0) localStorage.setItem(segmentsCountKey, count); else localStorage.removeItem(segmentsCountKey); @@ -79,7 +80,6 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { if (usesSegments(rbSegment)) this.updateSegmentCount(-1); return true; - } catch (e) { this.log.error(LOG_PREFIX + e); return false; @@ -116,7 +116,7 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { getChangeNumber(): number { const n = -1; - let value: string | number | null = localStorage.getItem(this.keys.buildRBSegmentTillKey()); + let value: string | number | null = localStorage.getItem(this.keys.buildRBSegmentsTillKey()); if (value !== null) { value = parseInt(value, 10); diff --git a/src/storages/inLocalStorage/SplitsCacheInLocal.ts b/src/storages/inLocalStorage/SplitsCacheInLocal.ts index 61988139..385125e3 100644 --- a/src/storages/inLocalStorage/SplitsCacheInLocal.ts +++ b/src/storages/inLocalStorage/SplitsCacheInLocal.ts @@ -57,16 +57,14 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { private _incrementCounts(split: ISplit) { try { - if (split) { - const ttKey = this.keys.buildTrafficTypeKey(split.trafficTypeName); - // @ts-expect-error - localStorage.setItem(ttKey, toNumber(localStorage.getItem(ttKey)) + 1); + const ttKey = this.keys.buildTrafficTypeKey(split.trafficTypeName); + // @ts-expect-error + localStorage.setItem(ttKey, toNumber(localStorage.getItem(ttKey)) + 1); - if (usesSegments(split)) { - const segmentsCountKey = this.keys.buildSplitsWithSegmentCountKey(); - // @ts-expect-error - localStorage.setItem(segmentsCountKey, toNumber(localStorage.getItem(segmentsCountKey)) + 1); - } + if (usesSegments(split)) { + const segmentsCountKey = this.keys.buildSplitsWithSegmentCountKey(); + // @ts-expect-error + localStorage.setItem(segmentsCountKey, toNumber(localStorage.getItem(segmentsCountKey)) + 1); } } catch (e) { this.log.error(LOG_PREFIX + e); diff --git a/src/storages/inMemory/__tests__/RBSegmentsCacheSync.spec.ts b/src/storages/inMemory/__tests__/RBSegmentsCacheSync.spec.ts new file mode 100644 index 00000000..16f4e6ae --- /dev/null +++ b/src/storages/inMemory/__tests__/RBSegmentsCacheSync.spec.ts @@ -0,0 +1,64 @@ +import { RBSegmentsCacheInMemory } from '../RBSegmentsCacheInMemory'; +import { RBSegmentsCacheInLocal } from '../../inLocalStorage/RBSegmentsCacheInLocal'; +import { KeyBuilderCS } from '../../KeyBuilderCS'; +import { rbSegment, rbSegmentWithInSegmentMatcher } from '../../__tests__/testUtils'; +import { IRBSegmentsCacheSync } from '../../types'; +import { fullSettings } from '../../../utils/settingsValidation/__tests__/settings.mocks'; + +const cacheInMemory = new RBSegmentsCacheInMemory(); +const cacheInLocal = new RBSegmentsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user')); + +describe.each([cacheInMemory, cacheInLocal])('RB SEGMENTS CACHE', (cache: IRBSegmentsCacheSync) => { + + beforeEach(() => { + cache.clear(); + }); + + test('clear should reset the cache state', () => { + cache.update([rbSegment], [], 1); + expect(cache.getChangeNumber()).toBe(1); + expect(cache.get(rbSegment.name)).not.toBeNull(); + + cache.clear(); + expect(cache.getChangeNumber()).toBe(-1); + expect(cache.get(rbSegment.name)).toBeNull(); + }); + + test('update should add and remove segments correctly', () => { + // Add segments + const updated1 = cache.update([rbSegment, rbSegmentWithInSegmentMatcher], [], 1); + expect(updated1).toBe(true); + expect(cache.get(rbSegment.name)).toEqual(rbSegment); + expect(cache.get(rbSegmentWithInSegmentMatcher.name)).toEqual(rbSegmentWithInSegmentMatcher); + expect(cache.getChangeNumber()).toBe(1); + + // Remove segments + const updated2 = cache.update([], [rbSegment], 2); + expect(updated2).toBe(true); + expect(cache.get(rbSegment.name)).toBeNull(); + expect(cache.get(rbSegmentWithInSegmentMatcher.name)).toEqual(rbSegmentWithInSegmentMatcher); + expect(cache.getChangeNumber()).toBe(2); + }); + + test('contains should check for segment existence correctly', () => { + cache.update([rbSegment, rbSegmentWithInSegmentMatcher], [], 1); + + expect(cache.contains(new Set([rbSegment.name]))).toBe(true); + expect(cache.contains(new Set([rbSegment.name, rbSegmentWithInSegmentMatcher.name]))).toBe(true); + expect(cache.contains(new Set(['nonexistent']))).toBe(false); + expect(cache.contains(new Set([rbSegment.name, 'nonexistent']))).toBe(false); + }); + + test('usesSegments should track segments usage correctly', () => { + expect(cache.usesSegments()).toBe(true); // Initially true when changeNumber is -1 + + cache.update([rbSegment], [], 1); // rbSegment doesn't have IN_SEGMENT matcher + expect(cache.usesSegments()).toBe(false); + + cache.update([rbSegmentWithInSegmentMatcher], [], 2); // rbSegmentWithInSegmentMatcher has IN_SEGMENT matcher + expect(cache.usesSegments()).toBe(true); + + cache.clear(); + expect(cache.usesSegments()).toBe(true); // True after clear since changeNumber is -1 + }); +}); From e22b28601f1ae4ff46467978975cc1c56b02dc58 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Thu, 27 Feb 2025 11:07:17 -0300 Subject: [PATCH 3/3] Update unit tests --- .../__tests__/RBSegmentsCacheSync.spec.ts | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) rename src/storages/{inMemory => }/__tests__/RBSegmentsCacheSync.spec.ts (62%) diff --git a/src/storages/inMemory/__tests__/RBSegmentsCacheSync.spec.ts b/src/storages/__tests__/RBSegmentsCacheSync.spec.ts similarity index 62% rename from src/storages/inMemory/__tests__/RBSegmentsCacheSync.spec.ts rename to src/storages/__tests__/RBSegmentsCacheSync.spec.ts index 16f4e6ae..1975b35e 100644 --- a/src/storages/inMemory/__tests__/RBSegmentsCacheSync.spec.ts +++ b/src/storages/__tests__/RBSegmentsCacheSync.spec.ts @@ -1,14 +1,14 @@ -import { RBSegmentsCacheInMemory } from '../RBSegmentsCacheInMemory'; -import { RBSegmentsCacheInLocal } from '../../inLocalStorage/RBSegmentsCacheInLocal'; -import { KeyBuilderCS } from '../../KeyBuilderCS'; -import { rbSegment, rbSegmentWithInSegmentMatcher } from '../../__tests__/testUtils'; -import { IRBSegmentsCacheSync } from '../../types'; -import { fullSettings } from '../../../utils/settingsValidation/__tests__/settings.mocks'; +import { RBSegmentsCacheInMemory } from '../inMemory/RBSegmentsCacheInMemory'; +import { RBSegmentsCacheInLocal } from '../inLocalStorage/RBSegmentsCacheInLocal'; +import { KeyBuilderCS } from '../KeyBuilderCS'; +import { rbSegment, rbSegmentWithInSegmentMatcher } from '../__tests__/testUtils'; +import { IRBSegmentsCacheSync } from '../types'; +import { fullSettings } from '../../utils/settingsValidation/__tests__/settings.mocks'; const cacheInMemory = new RBSegmentsCacheInMemory(); const cacheInLocal = new RBSegmentsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user')); -describe.each([cacheInMemory, cacheInLocal])('RB SEGMENTS CACHE', (cache: IRBSegmentsCacheSync) => { +describe.each([cacheInMemory, cacheInLocal])('Rule-based segments cache sync (Memory & LocalStorage)', (cache: IRBSegmentsCacheSync) => { beforeEach(() => { cache.clear(); @@ -26,18 +26,26 @@ describe.each([cacheInMemory, cacheInLocal])('RB SEGMENTS CACHE', (cache: IRBSeg test('update should add and remove segments correctly', () => { // Add segments - const updated1 = cache.update([rbSegment, rbSegmentWithInSegmentMatcher], [], 1); - expect(updated1).toBe(true); + expect(cache.update([rbSegment, rbSegmentWithInSegmentMatcher], [], 1)).toBe(true); expect(cache.get(rbSegment.name)).toEqual(rbSegment); expect(cache.get(rbSegmentWithInSegmentMatcher.name)).toEqual(rbSegmentWithInSegmentMatcher); expect(cache.getChangeNumber()).toBe(1); - // Remove segments - const updated2 = cache.update([], [rbSegment], 2); - expect(updated2).toBe(true); + // Remove a segment + expect(cache.update([], [rbSegment], 2)).toBe(true); expect(cache.get(rbSegment.name)).toBeNull(); expect(cache.get(rbSegmentWithInSegmentMatcher.name)).toEqual(rbSegmentWithInSegmentMatcher); expect(cache.getChangeNumber()).toBe(2); + + // Remove remaining segment + expect(cache.update([], [rbSegmentWithInSegmentMatcher], 3)).toBe(true); + expect(cache.get(rbSegment.name)).toBeNull(); + expect(cache.get(rbSegmentWithInSegmentMatcher.name)).toBeNull(); + expect(cache.getChangeNumber()).toBe(3); + + // No changes + expect(cache.update([], [rbSegmentWithInSegmentMatcher], 4)).toBe(false); + expect(cache.getChangeNumber()).toBe(4); }); test('contains should check for segment existence correctly', () => { @@ -47,6 +55,8 @@ describe.each([cacheInMemory, cacheInLocal])('RB SEGMENTS CACHE', (cache: IRBSeg expect(cache.contains(new Set([rbSegment.name, rbSegmentWithInSegmentMatcher.name]))).toBe(true); expect(cache.contains(new Set(['nonexistent']))).toBe(false); expect(cache.contains(new Set([rbSegment.name, 'nonexistent']))).toBe(false); + + cache.update([], [rbSegment, rbSegmentWithInSegmentMatcher], 2); }); test('usesSegments should track segments usage correctly', () => {