diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.segments.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.segments.test.ts index 5843bfe05c..f2db08abff 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.segments.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.segments.test.ts @@ -34,12 +34,14 @@ class TestQueries implements Queries { }, ) {} - async getFlag(key: string): Promise { - return this.data.flags?.find((flag) => flag.key === key); + getFlag(key: string, cb: (flag: Flag | undefined) => void): void { + const res = this.data.flags?.find((flag) => flag.key === key); + cb(res); } - async getSegment(key: string): Promise { - return this.data.segments?.find((segment) => segment.key === key); + getSegment(key: string, cb: (segment: Segment | undefined) => void): void { + const res = this.data.segments?.find((segment) => segment.key === key); + cb(res); } getBigSegmentsMembership( diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 94b9b53de4..ab9f53300d 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -12,7 +12,15 @@ import { subsystem, } from '@launchdarkly/js-sdk-common'; -import { LDClient, LDFlagsState, LDFlagsStateOptions, LDOptions, LDStreamProcessor } from './api'; +import { + LDClient, + LDFeatureStore, + LDFeatureStoreKindData, + LDFlagsState, + LDFlagsStateOptions, + LDOptions, + LDStreamProcessor, +} from './api'; import { BigSegmentStoreMembership } from './api/interfaces'; import BigSegmentsManager from './BigSegmentsManager'; import BigSegmentStoreStatusProvider from './BigSegmentStatusProviderImpl'; @@ -23,7 +31,7 @@ import PollingProcessor from './data_sources/PollingProcessor'; import Requestor from './data_sources/Requestor'; import StreamingProcessor from './data_sources/StreamingProcessor'; import { LDClientError } from './errors'; -import { allSeriesAsync } from './evaluation/collection'; +import { allAsync, allSeriesAsync } from './evaluation/collection'; import { Flag } from './evaluation/data/Flag'; import { Segment } from './evaluation/data/Segment'; import ErrorKinds from './evaluation/ErrorKinds'; @@ -38,7 +46,7 @@ import isExperiment from './events/isExperiment'; import NullEventProcessor from './events/NullEventProcessor'; import FlagsStateBuilder from './FlagsStateBuilder'; import Configuration from './options/Configuration'; -import AsyncStoreFacade from './store/AsyncStoreFacade'; +import { AsyncStoreFacade } from './store'; import VersionedDataKinds from './store/VersionedDataKinds'; enum InitState { @@ -64,7 +72,9 @@ export interface LDClientCallbacks { export default class LDClientImpl implements LDClient { private initState: InitState = InitState.Initializing; - private featureStore: AsyncStoreFacade; + private featureStore: LDFeatureStore; + + private asyncFeatureStore: AsyncStoreFacade; private updateProcessor: LDStreamProcessor; @@ -125,6 +135,7 @@ export default class LDClientImpl implements LDClient { const clientContext = new ClientContext(sdkKey, config, platform); const featureStore = config.featureStoreFactory(clientContext); + this.asyncFeatureStore = new AsyncStoreFacade(featureStore); const dataSourceUpdates = new DataSourceUpdates(featureStore, hasEventListeners, onUpdate); if (config.sendEvents && !config.offline && !config.diagnosticOptOut) { @@ -166,9 +177,7 @@ export default class LDClientImpl implements LDClient { ); } - const asyncFacade = new AsyncStoreFacade(featureStore); - - this.featureStore = asyncFacade; + this.featureStore = featureStore; const manager = new BigSegmentsManager( config.bigSegments?.store?.(clientContext), @@ -180,11 +189,11 @@ export default class LDClientImpl implements LDClient { this.bigSegmentStatusProviderInternal = manager.statusProvider as BigSegmentStoreStatusProvider; const queries: Queries = { - async getFlag(key: string): Promise { - return ((await asyncFacade.get(VersionedDataKinds.Features, key)) as Flag) ?? undefined; + getFlag(key: string, cb: (flag: Flag | undefined) => void): void { + featureStore.get(VersionedDataKinds.Features, key, (item) => cb(item as Flag)); }, - async getSegment(key: string): Promise { - return ((await asyncFacade.get(VersionedDataKinds.Segments, key)) as Segment) ?? undefined; + getSegment(key: string, cb: (segment: Segment | undefined) => void): void { + featureStore.get(VersionedDataKinds.Segments, key, (item) => cb(item as Segment)); }, getBigSegmentsMembership( userKey: string, @@ -232,37 +241,35 @@ export default class LDClientImpl implements LDClient { return this.initializedPromise; } - async variation( + variation( key: string, context: LDContext, defaultValue: any, callback?: (err: any, res: any) => void, ): Promise { - const res = await this.evaluateIfPossible(key, context, defaultValue, this.eventFactoryDefault); - if (!callback) { - return res.detail.value; - } - callback(null, res.detail.value); - return undefined; + return new Promise((resolve) => { + this.evaluateIfPossible(key, context, defaultValue, this.eventFactoryDefault, (res) => { + resolve(res.detail.value); + callback?.(null, res.detail.value); + }); + }); } - async variationDetail( + variationDetail( key: string, context: LDContext, defaultValue: any, callback?: (err: any, res: LDEvaluationDetail) => void, ): Promise { - const res = await this.evaluateIfPossible( - key, - context, - defaultValue, - this.eventFactoryWithReasons, - ); - callback?.(null, res.detail); - return res.detail; + return new Promise((resolve) => { + this.evaluateIfPossible(key, context, defaultValue, this.eventFactoryWithReasons, (res) => { + resolve(res.detail); + callback?.(null, res.detail); + }); + }); } - async allFlagsState( + allFlagsState( context: LDContext, options?: LDFlagsStateOptions, callback?: (err: Error | null, res: LDFlagsState) => void, @@ -271,67 +278,79 @@ export default class LDClientImpl implements LDClient { this.logger?.info('allFlagsState() called in offline mode. Returning empty state.'); const allFlagState = new FlagsStateBuilder(false, false).build(); callback?.(null, allFlagState); - return allFlagState; + return Promise.resolve(allFlagState); } const evalContext = Context.fromLDContext(context); if (!evalContext.valid) { this.logger?.info(`${evalContext.message ?? 'Invalid context.'}. Returning empty state.`); - return new FlagsStateBuilder(false, false).build(); + return Promise.resolve(new FlagsStateBuilder(false, false).build()); } - let valid = true; - if (!this.initialized()) { - const storeInitialized = await this.featureStore.initialized(); - if (storeInitialized) { - this.logger?.warn( - 'Called allFlagsState before client initialization; using last known' + - ' values from data store', - ); + return new Promise((resolve) => { + const doEval = (valid: boolean) => + this.featureStore.all(VersionedDataKinds.Features, (allFlags) => { + const builder = new FlagsStateBuilder(valid, !!options?.withReasons); + const clientOnly = !!options?.clientSideOnly; + const detailsOnlyIfTracked = !!options?.detailsOnlyForTrackedFlags; + + allAsync( + Object.values(allFlags), + (storeItem, iterCb) => { + const flag = storeItem as Flag; + if (clientOnly && !flag.clientSide) { + iterCb(true); + return; + } + this.evaluator.evaluateCb(flag, evalContext, (res) => { + if (res.isError) { + this.onError( + new Error( + `Error for feature flag "${flag.key}" while evaluating all flags: ${res.message}`, + ), + ); + } + const requireExperimentData = isExperiment(flag, res.detail.reason); + builder.addFlag( + flag, + res.detail.value, + res.detail.variationIndex ?? undefined, + res.detail.reason, + flag.trackEvents || requireExperimentData, + requireExperimentData, + detailsOnlyIfTracked, + ); + iterCb(true); + }); + }, + () => { + const res = builder.build(); + callback?.(null, res); + resolve(res); + }, + ); + }); + if (!this.initialized()) { + this.featureStore.initialized((storeInitialized) => { + let valid = true; + if (storeInitialized) { + this.logger?.warn( + 'Called allFlagsState before client initialization; using last known' + + ' values from data store', + ); + } else { + this.logger?.warn( + 'Called allFlagsState before client initialization. Data store not available; ' + + 'returning empty state', + ); + valid = false; + } + doEval(valid); + }); } else { - this.logger?.warn( - 'Called allFlagsState before client initialization. Data store not available; ' + - 'returning empty state', - ); - valid = false; - } - } - - const builder = new FlagsStateBuilder(valid, !!options?.withReasons); - const clientOnly = !!options?.clientSideOnly; - const detailsOnlyIfTracked = !!options?.detailsOnlyForTrackedFlags; - - const allFlags = await this.featureStore.all(VersionedDataKinds.Features); - await allSeriesAsync(Object.values(allFlags), async (storeItem) => { - const flag = storeItem as Flag; - if (clientOnly && !flag.clientSide) { - return true; - } - const res = await this.evaluator.evaluate(flag, evalContext); - if (res.isError) { - this.onError( - new Error( - `Error for feature flag "${flag.key}" while evaluating all flags: ${res.message}`, - ), - ); + doEval(true); } - const requireExperimentData = isExperiment(flag, res.detail.reason); - builder.addFlag( - flag, - res.detail.value, - res.detail.variationIndex ?? undefined, - res.detail.reason, - flag.trackEvents || requireExperimentData, - requireExperimentData, - detailsOnlyIfTracked, - ); - - return true; }); - - const res = builder.build(); - callback?.(null, res); - return res; } secureModeHash(context: LDContext): string { @@ -385,15 +404,17 @@ export default class LDClientImpl implements LDClient { callback?.(null, true); } - private async variationInternal( + private variationInternal( flagKey: string, context: LDContext, defaultValue: any, eventFactory: EventFactory, - ): Promise { + cb: (res: EvalResult) => void, + ): void { if (this.config.offline) { this.logger?.info('Variation called in offline mode. Returning default value.'); - return EvalResult.forError(ErrorKinds.ClientNotReady, undefined, defaultValue); + cb(EvalResult.forError(ErrorKinds.ClientNotReady, undefined, defaultValue)); + return; } const evalContext = Context.fromLDContext(context); if (!evalContext.valid) { @@ -402,54 +423,72 @@ export default class LDClientImpl implements LDClient { `${evalContext.message ?? 'Context not valid;'} returning default value.`, ), ); - return EvalResult.forError(ErrorKinds.UserNotSpecified, undefined, defaultValue); + cb(EvalResult.forError(ErrorKinds.UserNotSpecified, undefined, defaultValue)); + return; } - const flag = (await this.featureStore.get(VersionedDataKinds.Features, flagKey)) as Flag; - if (!flag) { - const error = new LDClientError(`Unknown feature flag "${flagKey}"; returning default value`); - this.onError(error); - const result = EvalResult.forError(ErrorKinds.FlagNotFound, undefined, defaultValue); - this.eventProcessor.sendEvent( - this.eventFactoryDefault.unknownFlagEvent(flagKey, evalContext, result.detail), + this.featureStore.get(VersionedDataKinds.Features, flagKey, (item) => { + const flag = item as Flag; + if (!flag) { + const error = new LDClientError( + `Unknown feature flag "${flagKey}"; returning default value`, + ); + this.onError(error); + const result = EvalResult.forError(ErrorKinds.FlagNotFound, undefined, defaultValue); + this.eventProcessor.sendEvent( + this.eventFactoryDefault.unknownFlagEvent(flagKey, evalContext, result.detail), + ); + cb(result); + return; + } + this.evaluator.evaluateCb( + flag, + evalContext, + (evalRes) => { + if ( + evalRes.detail.variationIndex === undefined || + evalRes.detail.variationIndex === null + ) { + this.logger?.debug('Result value is null in variation'); + evalRes.setDefault(defaultValue); + } + evalRes.events?.forEach((event) => { + this.eventProcessor.sendEvent(event); + }); + this.eventProcessor.sendEvent( + eventFactory.evalEvent(flag, evalContext, evalRes.detail, defaultValue), + ); + cb(evalRes); + }, + eventFactory, ); - return result; - } - const evalRes = await this.evaluator.evaluate(flag, evalContext, eventFactory); - if (evalRes.detail.variationIndex === undefined || evalRes.detail.variationIndex === null) { - this.logger?.debug('Result value is null in variation'); - evalRes.setDefault(defaultValue); - } - evalRes.events?.forEach((event) => { - this.eventProcessor.sendEvent(event); }); - this.eventProcessor.sendEvent( - eventFactory.evalEvent(flag, evalContext, evalRes.detail, defaultValue), - ); - return evalRes; } - private async evaluateIfPossible( + private evaluateIfPossible( flagKey: string, context: LDContext, defaultValue: any, eventFactory: EventFactory, - ): Promise { + cb: (res: EvalResult) => void, + ): void { if (!this.initialized()) { - const storeInitialized = await this.featureStore.initialized(); - if (storeInitialized) { + this.featureStore.initialized((storeInitialized) => { + if (storeInitialized) { + this.logger?.warn( + 'Variation called before LaunchDarkly client initialization completed' + + " (did you wait for the 'ready' event?) - using last known values from feature store", + ); + this.variationInternal(flagKey, context, defaultValue, eventFactory, cb); + return; + } this.logger?.warn( - 'Variation called before LaunchDarkly client initialization completed' + - " (did you wait for the 'ready' event?) - using last known values from feature store", + 'Variation called before LaunchDarkly client initialization completed (did you wait for the' + + "'ready' event?) - using default value", ); - return this.variationInternal(flagKey, context, defaultValue, eventFactory); - } - this.logger?.warn( - 'Variation called before LaunchDarkly client initialization completed (did you wait for the' + - "'ready' event?) - using default value", - ); - return EvalResult.forError(ErrorKinds.ClientNotReady, undefined, defaultValue); + cb(EvalResult.forError(ErrorKinds.ClientNotReady, undefined, defaultValue)); + }); } - return this.variationInternal(flagKey, context, defaultValue, eventFactory); + this.variationInternal(flagKey, context, defaultValue, eventFactory, cb); } } diff --git a/packages/shared/sdk-server/src/evaluation/Evaluator.ts b/packages/shared/sdk-server/src/evaluation/Evaluator.ts index fc1d3b3bf6..2abe12a9f6 100644 --- a/packages/shared/sdk-server/src/evaluation/Evaluator.ts +++ b/packages/shared/sdk-server/src/evaluation/Evaluator.ts @@ -23,6 +23,15 @@ import { Queries } from './Queries'; import Reasons from './Reasons'; import { getBucketBy, getOffVariation, getVariation } from './variations'; +/** + * PERFORMANCE NOTE: The evaluation algorithm uses callbacks instead of async/await to optimize + * performance. This is most important for collections where iterating through rules/clauses + * has substantial overhead if each iteration involves a promise. For evaluations which do not + * involve large collections the evaluation should not have to defer execution. Large collections + * cannot be iterated recursively because stack could become exhausted, when a collection is large + * we defer the execution of the iterations to prevent stack overflows. + */ + type BigSegmentStoreStatusString = 'HEALTHY' | 'STALE' | 'STORE_ERROR' | 'NOT_CONFIGURED'; const bigSegmentsStatusPriority: Record = { @@ -56,7 +65,7 @@ function computeUpdatedBigSegmentsStatus( return latest; } -class EvalState { +interface EvalState { events?: internal.InputEvalEvent[]; bigSegmentsStatus?: BigSegmentStoreStatusString; @@ -64,20 +73,24 @@ class EvalState { bigSegmentsMembership?: Record; } -class Match { - public readonly error = false; - - public readonly result?: EvalResult; - - constructor(public readonly isMatch: boolean) {} +interface Match { + error: false; + isMatch: boolean; + result: undefined; } -class MatchError { - public readonly error = true; +interface MatchError { + error: true; + isMatch: false; + result?: EvalResult; +} - public readonly isMatch = false; +function makeMatch(match: boolean): Match { + return { error: false, isMatch: match, result: undefined }; +} - constructor(public readonly result: EvalResult) {} +function makeError(result: EvalResult): MatchError { + return { error: true, isMatch: false, result }; } /** @@ -101,13 +114,52 @@ export default class Evaluator { } async evaluate(flag: Flag, context: Context, eventFactory?: EventFactory): Promise { - const state = new EvalState(); - const res = await this.evaluateInternal(flag, context, state, [], eventFactory); - if (state.bigSegmentsStatus) { - res.detail.reason = { ...res.detail.reason, bigSegmentsStatus: state.bigSegmentsStatus }; - } - res.events = state.events; - return res; + return new Promise((resolve) => { + const state: EvalState = {}; + this.evaluateInternal( + flag, + context, + state, + [], + (res) => { + if (state.bigSegmentsStatus) { + res.detail.reason = { + ...res.detail.reason, + bigSegmentsStatus: state.bigSegmentsStatus, + }; + } + res.events = state.events; + resolve(res); + }, + eventFactory, + ); + }); + } + + evaluateCb( + flag: Flag, + context: Context, + cb: (res: EvalResult) => void, + eventFactory?: EventFactory, + ) { + const state: EvalState = {}; + this.evaluateInternal( + flag, + context, + state, + [], + (res) => { + if (state.bigSegmentsStatus) { + res.detail.reason = { + ...res.detail.reason, + bigSegmentsStatus: state.bigSegmentsStatus, + }; + } + res.events = state.events; + cb(res); + }, + eventFactory, + ); } /** @@ -120,42 +172,50 @@ export default class Evaluator { * @param visitedFlags The flags that have been visited during this evaluation. * This is not part of the state, because it needs to be forked during prerequisite evaluations. */ - private async evaluateInternal( + private evaluateInternal( flag: Flag, context: Context, // eslint-disable-next-line @typescript-eslint/no-unused-vars state: EvalState, visitedFlags: string[], + cb: (res: EvalResult) => void, eventFactory?: EventFactory, - ): Promise { + ): void { if (!flag.on) { - return getOffVariation(flag, Reasons.Off); + cb(getOffVariation(flag, Reasons.Off)); + return; } - const prereqResult = await this.checkPrerequisites( + this.checkPrerequisites( flag, context, state, visitedFlags, - eventFactory, - ); - // If there is a prereq result, then prereqs have failed, or there was - // an error. - if (prereqResult) { - return prereqResult; - } + (res) => { + // If there is a prereq result, then prereqs have failed, or there was + // an error. + if (res) { + cb(res); + return; + } - const targetRes = evalTargets(flag, context); - if (targetRes) { - return targetRes; - } + const targetRes = evalTargets(flag, context); + if (targetRes) { + cb(targetRes); + return; + } - const ruleRes = await this.evaluateRules(flag, context, state); - if (ruleRes) { - return ruleRes; - } + this.evaluateRules(flag, context, state, (evalRes) => { + if (evalRes) { + cb(evalRes); + return; + } - return this.variationForContext(flag.fallthrough, context, flag, Reasons.Fallthrough); + cb(this.variationForContext(flag.fallthrough, context, flag, Reasons.Fallthrough)); + }); + }, + eventFactory, + ); } /** @@ -164,77 +224,81 @@ export default class Evaluator { * @param context The context to evaluate the prerequisites against. * @param state used to accumulate prerequisite events. * @param visitedFlags Used to detect cycles in prerequisite evaluation. - * @returns An {@link EvalResult} containing an error result or `undefined` if the prerequisites + * @param cb A callback which is executed when prerequisite checks are complete it is called with + * an {@link EvalResult} containing an error result or `undefined` if the prerequisites * are met. */ - private async checkPrerequisites( + private checkPrerequisites( flag: Flag, context: Context, state: EvalState, visitedFlags: string[], + cb: (res: EvalResult | undefined) => void, eventFactory?: EventFactory, - ): Promise { + ): void { let prereqResult: EvalResult | undefined; if (!flag.prerequisites || !flag.prerequisites.length) { - return undefined; + cb(undefined); + return; } // On any error conditions the prereq result will be set, so we do not need // the result of the series evaluation. - await allSeriesAsync(flag.prerequisites, async (prereq) => { - if (visitedFlags.indexOf(prereq.key) !== -1) { - prereqResult = EvalResult.forError( - ErrorKinds.MalformedFlag, - `Prerequisite of ${flag.key} causing a circular reference.` + - ' This is probably a temporary condition due to an incomplete update.', - ); - return false; - } - const updatedVisitedFlags = [...visitedFlags, prereq.key]; - const prereqFlag = await this.queries.getFlag(prereq.key); - - if (!prereqFlag) { - prereqResult = getOffVariation(flag, Reasons.prerequisiteFailed(prereq.key)); - return false; - } - - const evalResult = await this.evaluateInternal( - prereqFlag, - context, - state, - updatedVisitedFlags, - eventFactory, - ); - - // eslint-disable-next-line no-param-reassign - state.events = state.events ?? []; - - if (eventFactory) { - state.events.push( - eventFactory.evalEvent(prereqFlag, context, evalResult.detail, null, flag), - ); - } - - if (evalResult.isError) { - prereqResult = evalResult; - return false; - } - - if (evalResult.isOff || evalResult.detail.variationIndex !== prereq.variation) { - prereqResult = getOffVariation(flag, Reasons.prerequisiteFailed(prereq.key)); - return false; - } - return true; - }); - - if (prereqResult) { - return prereqResult; - } + allSeriesAsync( + flag.prerequisites, + (prereq, _index, iterCb) => { + if (visitedFlags.indexOf(prereq.key) !== -1) { + prereqResult = EvalResult.forError( + ErrorKinds.MalformedFlag, + `Prerequisite of ${flag.key} causing a circular reference.` + + ' This is probably a temporary condition due to an incomplete update.', + ); + iterCb(true); + return; + } + const updatedVisitedFlags = [...visitedFlags, prereq.key]; + this.queries.getFlag(prereq.key, (prereqFlag) => { + if (!prereqFlag) { + prereqResult = getOffVariation(flag, Reasons.prerequisiteFailed(prereq.key)); + iterCb(false); + return; + } - // There were no prereqResults for errors or failed prerequisites. - // So they have all passed. - return undefined; + this.evaluateInternal( + prereqFlag, + context, + state, + updatedVisitedFlags, + (res) => { + // eslint-disable-next-line no-param-reassign + state.events = state.events ?? []; + + if (eventFactory) { + state.events.push( + eventFactory.evalEvent(prereqFlag, context, res.detail, null, flag), + ); + } + + if (res.isError) { + prereqResult = res; + return iterCb(false); + } + + if (res.isOff || res.detail.variationIndex !== prereq.variation) { + prereqResult = getOffVariation(flag, Reasons.prerequisiteFailed(prereq.key)); + return iterCb(false); + } + return iterCb(true); + }, + eventFactory, + ); + }); + }, + () => { + cb(prereqResult); + }, + ); } /** @@ -243,69 +307,87 @@ export default class Evaluator { * @param flag The flag to evaluate rules for. * @param context The context to evaluate the rules against. * @param state The current evaluation state. - * @returns + * @param cb Callback called when rule evaluation is complete, it will be called with either + * an {@link EvalResult} or 'undefined'. */ - private async evaluateRules( + private evaluateRules( flag: Flag, context: Context, state: EvalState, - ): Promise { + cb: (res: EvalResult | undefined) => void, + ): void { let ruleResult: EvalResult | undefined; - await firstSeriesAsync(flag.rules, async (rule, ruleIndex) => { - ruleResult = await this.ruleMatchContext(flag, rule, ruleIndex, context, state, []); - return !!ruleResult; - }); - - return ruleResult; + firstSeriesAsync( + flag.rules, + (rule, ruleIndex, iterCb: (res: boolean) => void) => { + this.ruleMatchContext(flag, rule, ruleIndex, context, state, [], (res) => { + ruleResult = res; + iterCb(!!res); + }); + }, + () => cb(ruleResult), + ); } - private async clauseMatchContext( + private clauseMatchContext( clause: Clause, context: Context, segmentsVisited: string[], state: EvalState, - ): Promise { + cb: (res: MatchOrError) => void, + ): void { let errorResult: EvalResult | undefined; if (clause.op === 'segmentMatch') { - const match = await firstSeriesAsync(clause.values, async (value) => { - const segment = await this.queries.getSegment(value); - if (segment) { - if (segmentsVisited.includes(segment.key)) { - errorResult = EvalResult.forError( - ErrorKinds.MalformedFlag, - `Segment rule referencing segment ${segment.key} caused a circular reference. ` + - 'This is probably a temporary condition due to an incomplete update', - ); - // There was an error, so stop checking further segments. - return true; - } - - const newVisited = [...segmentsVisited, segment?.key]; - const res = await this.segmentMatchContext(segment, context, state, newVisited); - if (res.error) { - errorResult = res.result; + firstSeriesAsync( + clause.values, + (value, _index, iterCb) => { + this.queries.getSegment(value, (segment) => { + if (segment) { + if (segmentsVisited.includes(segment.key)) { + errorResult = EvalResult.forError( + ErrorKinds.MalformedFlag, + `Segment rule referencing segment ${segment.key} caused a circular reference. ` + + 'This is probably a temporary condition due to an incomplete update', + ); + // There was an error, so stop checking further segments. + iterCb(true); + return; + } + + const newVisited = [...segmentsVisited, segment?.key]; + this.segmentMatchContext(segment, context, state, newVisited, (res) => { + if (res.error) { + errorResult = res.result; + } + iterCb(res.error || res.isMatch); + }); + } else { + iterCb(false); + } + }); + }, + (match) => { + if (errorResult) { + return cb(makeError(errorResult)); } - return res.error || res.isMatch; - } - - return false; - }); - - if (errorResult) { - return new MatchError(errorResult); - } - return new Match(maybeNegate(clause, match)); + return cb(makeMatch(maybeNegate(clause, match))); + }, + ); + return; } // This is after segment matching, which does not use the reference. if (!clause.attributeReference.isValid) { - return new MatchError( - EvalResult.forError(ErrorKinds.MalformedFlag, 'Invalid attribute reference in clause'), + cb( + makeError( + EvalResult.forError(ErrorKinds.MalformedFlag, 'Invalid attribute reference in clause'), + ), ); + return; } - return new Match(matchClauseWithoutSegmentOperations(clause, context)); + cb(makeMatch(matchClauseWithoutSegmentOperations(clause, context))); } /** @@ -314,34 +396,44 @@ export default class Evaluator { * @param rule The rule to match. * @param rule The index of the rule. * @param context The context to match the rule against. - * @returns An {@link EvalResult} or `undefined` if there are no matches or errors. + * @param cb Called when matching is complete with an {@link EvalResult} or `undefined` if there + * are no matches or errors. */ - private async ruleMatchContext( + private ruleMatchContext( flag: Flag, rule: FlagRule, ruleIndex: number, context: Context, state: EvalState, segmentsVisited: string[], - ): Promise { + cb: (res: EvalResult | undefined) => void, + ): void { if (!rule.clauses) { - return undefined; + cb(undefined); + return; } let errorResult: EvalResult | undefined; - const match = await allSeriesAsync(rule.clauses, async (clause) => { - const res = await this.clauseMatchContext(clause, context, segmentsVisited, state); - errorResult = res.result; - return res.error || res.isMatch; - }); - - if (errorResult) { - return errorResult; - } + allSeriesAsync( + rule.clauses, + (clause, _index, iterCb) => { + this.clauseMatchContext(clause, context, segmentsVisited, state, (res) => { + errorResult = res.result; + return iterCb(res.error || res.isMatch); + }); + }, + (match) => { + if (errorResult) { + return cb(errorResult); + } - if (match) { - return this.variationForContext(rule, context, flag, Reasons.ruleMatch(rule.id, ruleIndex)); - } - return undefined; + if (match) { + return cb( + this.variationForContext(rule, context, flag, Reasons.ruleMatch(rule.id, ruleIndex)), + ); + } + return cb(undefined); + }, + ); } private variationForContext( @@ -418,98 +510,114 @@ export default class Evaluator { ); } - async segmentRuleMatchContext( + segmentRuleMatchContext( segment: Segment, rule: SegmentRule, context: Context, state: EvalState, segmentsVisited: string[], - ): Promise { + cb: (res: MatchOrError) => void, + ): void { let errorResult: EvalResult | undefined; - const match = await allSeriesAsync(rule.clauses, async (clause) => { - const res = await this.clauseMatchContext(clause, context, segmentsVisited, state); - errorResult = res.result; - return res.error || res.isMatch; - }); - - if (errorResult) { - return new MatchError(errorResult); - } + allSeriesAsync( + rule.clauses, + (clause, _index, iterCb) => { + this.clauseMatchContext(clause, context, segmentsVisited, state, (res) => { + errorResult = res.result; + iterCb(res.error || res.isMatch); + }); + }, + (match) => { + if (errorResult) { + return cb(makeError(errorResult)); + } - if (match) { - if (rule.weight === undefined) { - return new Match(match); - } - const bucketBy = getBucketBy(false, rule.bucketByAttributeReference); - if (!bucketBy.isValid) { - return new MatchError( - EvalResult.forError(ErrorKinds.MalformedFlag, 'Invalid attribute reference in clause'), - ); - } + if (match) { + if (rule.weight === undefined) { + return cb(makeMatch(match)); + } + const bucketBy = getBucketBy(false, rule.bucketByAttributeReference); + if (!bucketBy.isValid) { + return cb( + makeError( + EvalResult.forError( + ErrorKinds.MalformedFlag, + 'Invalid attribute reference in clause', + ), + ), + ); + } - const [bucket] = this.bucketer.bucket( - context, - segment.key, - bucketBy, - segment.salt || '', - rule.rolloutContextKind, - ); - return new Match(bucket < rule.weight / 100000.0); - } + const [bucket] = this.bucketer.bucket( + context, + segment.key, + bucketBy, + segment.salt || '', + rule.rolloutContextKind, + ); + return cb(makeMatch(bucket < rule.weight / 100000.0)); + } - return new Match(false); + return cb(makeMatch(false)); + }, + ); } // eslint-disable-next-line class-methods-use-this - async simpleSegmentMatchContext( + simpleSegmentMatchContext( segment: Segment, context: Context, state: EvalState, segmentsVisited: string[], - ): Promise { + cb: (res: MatchOrError) => void, + ): void { if (!segment.unbounded) { const includeExclude = matchSegmentTargets(segment, context); if (includeExclude !== undefined) { - return new Match(includeExclude); + cb(makeMatch(includeExclude)); + return; } } let evalResult: EvalResult | undefined; - const matched = await firstSeriesAsync(segment.rules, async (rule) => { - const res = await this.segmentRuleMatchContext( - segment, - rule, - context, - state, - segmentsVisited, - ); - evalResult = res.result; - return res.error || res.isMatch; - }); - if (evalResult) { - return new MatchError(evalResult); - } + firstSeriesAsync( + segment.rules, + (rule, _index, iterCb) => { + this.segmentRuleMatchContext(segment, rule, context, state, segmentsVisited, (res) => { + evalResult = res.result; + return iterCb(res.error || res.isMatch); + }); + }, + (matched) => { + if (evalResult) { + return cb(makeError(evalResult)); + } - return new Match(matched); + return cb(makeMatch(matched)); + }, + ); } - async segmentMatchContext( + segmentMatchContext( segment: Segment, context: Context, // eslint-disable-next-line @typescript-eslint/no-unused-vars state: EvalState, // eslint-disable-next-line @typescript-eslint/no-unused-vars segmentsVisited: string[], - ): Promise { + cb: (res: MatchOrError) => void, + ): void { if (!segment.unbounded) { - return this.simpleSegmentMatchContext(segment, context, state, segmentsVisited); + this.simpleSegmentMatchContext(segment, context, state, segmentsVisited, cb); + return; } const bigSegmentKind = segment.unboundedContextKind || 'user'; const keyForBigSegment = context.key(bigSegmentKind); if (!keyForBigSegment) { - return new Match(false); + cb(makeMatch(false)); + return; } if (!segment.generation) { @@ -522,7 +630,8 @@ export default class Evaluator { state.bigSegmentsStatus, 'NOT_CONFIGURED', ); - return new Match(false); + cb(makeMatch(false)); + return; } if (state.bigSegmentsMembership && state.bigSegmentsMembership[keyForBigSegment]) { @@ -531,44 +640,45 @@ export default class Evaluator { // again. Even if multiple Big Segments are being referenced, the membership includes // *all* of the user's segment memberships. - return this.bigSegmentMatchContext( + this.bigSegmentMatchContext( state.bigSegmentsMembership[keyForBigSegment], segment, context, state, - ); + ).then(cb); + return; } - const result = await this.queries.getBigSegmentsMembership(keyForBigSegment); - - // eslint-disable-next-line no-param-reassign - state.bigSegmentsMembership = state.bigSegmentsMembership || {}; - if (result) { - const [membership, status] = result; - // eslint-disable-next-line no-param-reassign - state.bigSegmentsMembership[keyForBigSegment] = membership; - // eslint-disable-next-line no-param-reassign - state.bigSegmentsStatus = computeUpdatedBigSegmentsStatus( - state.bigSegmentsStatus, - status as BigSegmentStoreStatusString, - ); - } else { + this.queries.getBigSegmentsMembership(keyForBigSegment).then((result) => { // eslint-disable-next-line no-param-reassign - state.bigSegmentsStatus = computeUpdatedBigSegmentsStatus( - state.bigSegmentsStatus, - 'NOT_CONFIGURED', - ); - } - /* eslint-enable no-param-reassign */ - return this.bigSegmentMatchContext( - state.bigSegmentsMembership[keyForBigSegment], - segment, - context, - state, - ); + state.bigSegmentsMembership = state.bigSegmentsMembership || {}; + if (result) { + const [membership, status] = result; + // eslint-disable-next-line no-param-reassign + state.bigSegmentsMembership[keyForBigSegment] = membership; + // eslint-disable-next-line no-param-reassign + state.bigSegmentsStatus = computeUpdatedBigSegmentsStatus( + state.bigSegmentsStatus, + status as BigSegmentStoreStatusString, + ); + } else { + // eslint-disable-next-line no-param-reassign + state.bigSegmentsStatus = computeUpdatedBigSegmentsStatus( + state.bigSegmentsStatus, + 'NOT_CONFIGURED', + ); + } + /* eslint-enable no-param-reassign */ + this.bigSegmentMatchContext( + state.bigSegmentsMembership[keyForBigSegment], + segment, + context, + state, + ).then(cb); + }); } - async bigSegmentMatchContext( + bigSegmentMatchContext( membership: BigSegmentStoreMembership | null, segment: Segment, context: Context, @@ -576,12 +686,15 @@ export default class Evaluator { ): Promise { const segmentRef = makeBigSegmentRef(segment); const included = membership?.[segmentRef]; - // Typically null is not checked because we filter it from the data - // we get in flag updates. Here it is checked because big segment data - // will be contingent on the store that implements it. - if (included !== undefined && included !== null) { - return new Match(included); - } - return this.simpleSegmentMatchContext(segment, context, state, []); + return new Promise((resolve) => { + // Typically null is not checked because we filter it from the data + // we get in flag updates. Here it is checked because big segment data + // will be contingent on the store that implements it. + if (included !== undefined && included !== null) { + resolve(makeMatch(included)); + return; + } + this.simpleSegmentMatchContext(segment, context, state, [], resolve); + }); } } diff --git a/packages/shared/sdk-server/src/evaluation/Queries.ts b/packages/shared/sdk-server/src/evaluation/Queries.ts index 4bcd2677d1..9737f1848b 100644 --- a/packages/shared/sdk-server/src/evaluation/Queries.ts +++ b/packages/shared/sdk-server/src/evaluation/Queries.ts @@ -9,8 +9,8 @@ import { Segment } from './data/Segment'; * @internal */ export interface Queries { - getFlag(key: string): Promise; - getSegment(key: string): Promise; + getFlag(key: string, cb: (flag: Flag | undefined) => void): void; + getSegment(key: string, cb: (segment: Segment | undefined) => void): void; getBigSegmentsMembership( userKey: string, ): Promise<[BigSegmentStoreMembership | null, string] | undefined>; diff --git a/packages/shared/sdk-server/src/evaluation/collection.ts b/packages/shared/sdk-server/src/evaluation/collection.ts index 997e8a986d..709acde953 100644 --- a/packages/shared/sdk-server/src/evaluation/collection.ts +++ b/packages/shared/sdk-server/src/evaluation/collection.ts @@ -18,59 +18,102 @@ export function firstResult( return res; } -async function seriesAsync( +const ITERATION_RECURSION_LIMIT = 50; + +function seriesAsync( collection: T[] | undefined, - check: (val: T, index: number) => Promise, + check: (val: T, index: number, cb: (res: boolean) => void) => void, all: boolean, -) { + index: number, + cb: (res: boolean) => void, +): void { if (!collection) { - return false; + cb(false); + return; } - for (let index = 0; index < collection.length; index += 1) { - // This warning is to encourage starting many operations at once. - // In this case we only want to evaluate until we encounter something that - // doesn't match. Versus starting all the evaluations and then letting them - // all resolve. - // eslint-disable-next-line no-await-in-loop - const res = await check(collection[index], index); - // If we want all checks to pass, then we return on any failed check. - // If we want only a single result to pass, then we return on a true result. - if (all) { - if (!res) { - return false; + if (index < collection?.length) { + check(collection[index], index, (res) => { + if (all) { + if (!res) { + cb(false); + return; + } + } else if (res) { + cb(true); + return; + } + if (collection.length > ITERATION_RECURSION_LIMIT) { + // When we hit the recursion limit we defer execution + // by using a resolved promise. This is similar to using setImmediate + // but more portable. + Promise.resolve().then(() => { + seriesAsync(collection, check, all, index + 1, cb); + }); + } else { + seriesAsync(collection, check, all, index + 1, cb); } - } else if (res) { - return true; - } + }); + } else { + cb(all); } - // In the case of 'all', getting here means all checks passed. - // In the case of 'first', this means no checks passed. - return all; } /** * Iterate a collection in series awaiting each check operation. * @param collection The collection to iterate. * @param check The check to perform for each item in the container. - * @returns True if all items pass the check. + * @param cb Called with true if all items pass the check. */ -export async function allSeriesAsync( +export function allSeriesAsync( collection: T[] | undefined, - check: (val: T, index: number) => Promise, -): Promise { - return seriesAsync(collection, check, true); + check: (val: T, index: number, cb: (res: boolean) => void) => void, + cb: (res: boolean) => void, +): void { + seriesAsync(collection, check, true, 0, cb); } /** * Iterate a collection in series awaiting each check operation. * @param collection The collection to iterate. * @param check The check to perform for each item in the container. - * @returns True on the first item that passes the check. False if no items - * pass. + * @param cb called with true on the first item that passes the check. False + * means no items passed the check. + */ +export function firstSeriesAsync( + collection: T[] | undefined, + check: (val: T, index: number, cb: (res: boolean) => void) => void, + cb: (res: boolean) => void, +): void { + seriesAsync(collection, check, false, 0, cb); +} + +/** + * Iterate a collection and execute the the given check operation + * for all items concurrently. + * @param collection The collection to iterate. + * @param check The check to run for each item. + * @param cb Callback executed when all items have been checked. The callback + * will be called with true if each item resulted in true, otherwise it will + * be called with false. */ -export async function firstSeriesAsync( +export function allAsync( collection: T[] | undefined, - check: (val: T, index: number) => Promise, -): Promise { - return seriesAsync(collection, check, false); + check: (val: T, cb: (res: boolean) => void) => void, + cb: (res: boolean | null | undefined) => void, +): void { + if (!collection) { + cb(false); + return; + } + + Promise.all( + collection?.map( + (item) => + new Promise((resolve) => { + check(item, resolve); + }), + ), + ).then((results) => { + cb(results.every((success) => success)); + }); }