From 62f7b4298be66fdef673be946f3c812efe7ba43e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 10 Aug 2023 11:32:42 -0700 Subject: [PATCH 01/19] feat: Do not use async for evaluation hotpath. --- .../shared/sdk-server/src/LDClientImpl.ts | 63 ++- .../sdk-server/src/evaluation/Evaluator.ts | 519 ++++++++++-------- .../sdk-server/src/evaluation/collection.ts | 55 +- 3 files changed, 355 insertions(+), 282 deletions(-) diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 94b9b53de..00bfab52f 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -302,36 +302,43 @@ export default class LDClientImpl implements LDClient { 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}`, - ), - ); - } - 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; - }); + return new Promise((resolve) => { + 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}`, + ), + ); + } + 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, + ); - const res = builder.build(); - callback?.(null, res); - return res; + return true; + }, + () => { + const res = builder.build(); + callback?.(null, res); + resolve(res); + }, + ); + }); } secureModeHash(context: LDContext): string { diff --git a/packages/shared/sdk-server/src/evaluation/Evaluator.ts b/packages/shared/sdk-server/src/evaluation/Evaluator.ts index 55b563b27..b7bf6de89 100644 --- a/packages/shared/sdk-server/src/evaluation/Evaluator.ts +++ b/packages/shared/sdk-server/src/evaluation/Evaluator.ts @@ -101,13 +101,25 @@ 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 = new 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, + ); + }); } /** @@ -120,42 +132,49 @@ 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[], + // visitedFlags: string[], + cb: (res: EvalResult) => void, eventFactory?: EventFactory, - ): Promise { + ): void { if (!flag.on) { - return getOffVariation(flag, Reasons.Off); + cb(getOffVariation(flag, Reasons.Off)); } - 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; - } + // visitedFlags, + (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, (eval_res) => { + if (eval_res) { + cb(eval_res); + return; + } - return this.variationForContext(flag.fallthrough, context, flag, Reasons.Fallthrough); + cb(this.variationForContext(flag.fallthrough, context, flag, Reasons.Fallthrough)); + }); + }, + eventFactory, + ); } /** @@ -167,74 +186,76 @@ export default class Evaluator { * @returns 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[], + // 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; - } + allSeriesAsync( + flag.prerequisites, + async (prereq, _index, prereq_cb) => { + // 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)); + prereq_cb(false); + return; + } - const evalResult = await this.evaluateInternal( - prereqFlag, - context, - state, - updatedVisitedFlags, - eventFactory, - ); + 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), + ); + } - // eslint-disable-next-line no-param-reassign - state.events = state.events ?? []; + if (res.isError) { + prereqResult = res; + return prereq_cb(false); + } - if (eventFactory) { - state.events.push( - eventFactory.evalEvent(prereqFlag, context, evalResult.detail, null, flag), + if (res.isOff || res.detail.variationIndex !== prereq.variation) { + prereqResult = getOffVariation(flag, Reasons.prerequisiteFailed(prereq.key)); + return prereq_cb(false); + } + return prereq_cb(true); + }, + eventFactory, ); - } - - 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; - } - - // There were no prereqResults for errors or failed prerequisites. - // So they have all passed. - return undefined; + }, + () => { + cb(prereqResult); + }, + ); } /** @@ -245,67 +266,83 @@ export default class Evaluator { * @param state The current evaluation state. * @returns */ - 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, ruleCb: (res: boolean) => void) => { + this.ruleMatchContext(flag, rule, ruleIndex, context, state, [], (res) => { + ruleResult = res; + ruleCb(!!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; - } + firstSeriesAsync( + clause.values, + async (value, _index, innerCB) => { + 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. + innerCB(true); + return; + } - const newVisited = [...segmentsVisited, segment?.key]; - const res = await this.segmentMatchContext(segment, context, state, newVisited); - if (res.error) { - errorResult = res.result; + const newVisited = [...segmentsVisited, segment?.key]; + this.segmentMatchContext(segment, context, state, newVisited, (res) => { + if (res.error) { + errorResult = res.result; + } + innerCB(res.error || res.isMatch); + }); + innerCB(false); + } + }, + (match) => { + if (errorResult) { + return cb(new MatchError(errorResult)); } - return res.error || res.isMatch; - } - - return false; - }); - - if (errorResult) { - return new MatchError(errorResult); - } - return new Match(match); + return cb(new Match(match)); + }, + ); + // TODO: Should this return here? + 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( + new MatchError( + EvalResult.forError(ErrorKinds.MalformedFlag, 'Invalid attribute reference in clause'), + ), ); + return; } - return new Match(matchClauseWithoutSegmentOperations(clause, context)); + cb(new Match(matchClauseWithoutSegmentOperations(clause, context))); } /** @@ -316,32 +353,40 @@ export default class Evaluator { * @param context The context to match the rule against. * @returns 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; + 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, rule_cb) => { + this.clauseMatchContext(clause, context, segmentsVisited, state, (res) => { + errorResult = res.result; + return rule_cb(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 +463,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, innerCb) => { + this.clauseMatchContext(clause, context, segmentsVisited, state, (res) => { + errorResult = res.result; + innerCb(res.error || res.isMatch); + }); + }, + (match) => { + if (errorResult) { + return cb(new MatchError(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(new Match(match)); + } + const bucketBy = getBucketBy(false, rule.bucketByAttributeReference); + if (!bucketBy.isValid) { + return cb( + new MatchError( + 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(new Match(bucket < rule.weight / 100000.0)); + } - return new Match(false); + return cb(new Match(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(new Match(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, innerCb) => { + this.segmentRuleMatchContext(segment, rule, context, state, segmentsVisited, (res) => { + evalResult = res.result; + return innerCb(res.error || res.isMatch); + }); + }, + (matched) => { + if (evalResult) { + return cb(new MatchError(evalResult)); + } - return new Match(matched); + return cb(new Match(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(new Match(false)); + return; } if (!segment.generation) { @@ -522,7 +583,8 @@ export default class Evaluator { state.bigSegmentsStatus, 'NOT_CONFIGURED', ); - return new Match(false); + cb(new Match(false)); + return; } if (state.bigSegmentsMembership && state.bigSegmentsMembership[keyForBigSegment]) { @@ -531,44 +593,44 @@ 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); } - 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; + this.queries.getBigSegmentsMembership(keyForBigSegment).then((result) => { // 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 */ - 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 +638,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(new Match(included)); + return; + } + this.simpleSegmentMatchContext(segment, context, state, [], resolve); + }); } } diff --git a/packages/shared/sdk-server/src/evaluation/collection.ts b/packages/shared/sdk-server/src/evaluation/collection.ts index 997e8a986..afb962523 100644 --- a/packages/shared/sdk-server/src/evaluation/collection.ts +++ b/packages/shared/sdk-server/src/evaluation/collection.ts @@ -18,34 +18,33 @@ export function firstResult( return res; } -async function seriesAsync( +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. + const item = collection[index]; + const resHandler = (res: boolean) => { if (all) { if (!res) { - return false; + return cb(false); } } else if (res) { - return true; + return cb(true); } - } - // In the case of 'all', getting here means all checks passed. - // In the case of 'first', this means no checks passed. - return all; + const nextIndex = index + 1; + if (nextIndex < collection?.length) { + seriesAsync(collection, check, all, nextIndex, resHandler); + } + return cb(true); + }; + check(item, index, resHandler); } /** @@ -54,11 +53,12 @@ async function seriesAsync( * @param check The check to perform for each item in the container. * @returns 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); } /** @@ -68,9 +68,10 @@ export async function allSeriesAsync( * @returns True on the first item that passes the check. False if no items * pass. */ -export async function firstSeriesAsync( +export function firstSeriesAsync( collection: T[] | undefined, - check: (val: T, index: number) => Promise, -): Promise { - return seriesAsync(collection, check, false); + check: (val: T, index: number, cb: (res: boolean) => void) => void, + cb: (res: boolean) => void, +): void { + seriesAsync(collection, check, false, 0, cb); } From bdebdff5b01b8f801c4f83e711ff9645d903b6bc Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 10 Aug 2023 11:49:23 -0700 Subject: [PATCH 02/19] feat: Switch to es2017 target to ensure native async/await. --- packages/sdk/akamai-base/tsconfig.json | 2 +- packages/sdk/akamai-edgekv/tsconfig.json | 2 +- packages/sdk/cloudflare/tsconfig.json | 2 +- packages/sdk/server-node/package.json | 4 ++-- packages/sdk/server-node/tsconfig.json | 2 +- packages/sdk/vercel/tsconfig.json | 2 +- packages/shared/akamai-edgeworker-sdk/tsconfig.json | 2 +- packages/shared/common/package.json | 2 +- packages/shared/common/tsconfig.json | 2 +- packages/shared/sdk-server-edge/tsconfig.json | 2 +- packages/shared/sdk-server/package.json | 4 ++-- packages/shared/sdk-server/tsconfig.json | 2 +- packages/store/node-server-sdk-dynamodb/tsconfig.json | 2 +- packages/store/node-server-sdk-redis/tsconfig.json | 2 +- 14 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/sdk/akamai-base/tsconfig.json b/packages/sdk/akamai-base/tsconfig.json index aa1b75233..763d67b8b 100644 --- a/packages/sdk/akamai-base/tsconfig.json +++ b/packages/sdk/akamai-base/tsconfig.json @@ -3,7 +3,7 @@ // Uses "." so it can load package.json. "rootDir": ".", "outDir": "dist", - "target": "es6", + "target": "es2017", "lib": ["ESNext"], "module": "es6", "strict": true, diff --git a/packages/sdk/akamai-edgekv/tsconfig.json b/packages/sdk/akamai-edgekv/tsconfig.json index 7a5e98034..647a66011 100644 --- a/packages/sdk/akamai-edgekv/tsconfig.json +++ b/packages/sdk/akamai-edgekv/tsconfig.json @@ -3,7 +3,7 @@ // Uses "." so it can load package.json. "rootDir": ".", "outDir": "dist", - "target": "es6", + "target": "es2017", "lib": ["es6"], "module": "es6", "strict": true, diff --git a/packages/sdk/cloudflare/tsconfig.json b/packages/sdk/cloudflare/tsconfig.json index ba2b0ea35..3192817ef 100644 --- a/packages/sdk/cloudflare/tsconfig.json +++ b/packages/sdk/cloudflare/tsconfig.json @@ -3,7 +3,7 @@ // Uses "." so it can load package.json. "rootDir": ".", "outDir": "dist", - "target": "es6", + "target": "es2017", "lib": ["es6"], "module": "commonjs", "strict": true, diff --git a/packages/sdk/server-node/package.json b/packages/sdk/server-node/package.json index 93ff4b1c6..663d1a7d7 100644 --- a/packages/sdk/server-node/package.json +++ b/packages/sdk/server-node/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/node-server-sdk", - "version": "8.1.1", + "version": "8.2.0-beta.1", "description": "LaunchDarkly Server-Side SDK for Node.js", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/server-node", "repository": { @@ -46,7 +46,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@launchdarkly/js-server-sdk-common": "1.0.7", + "@launchdarkly/js-server-sdk-common": "1.1.0-beta.1", "https-proxy-agent": "^5.0.1", "launchdarkly-eventsource": "2.0.0" }, diff --git a/packages/sdk/server-node/tsconfig.json b/packages/sdk/server-node/tsconfig.json index 1b9ac79b8..e4158b054 100644 --- a/packages/sdk/server-node/tsconfig.json +++ b/packages/sdk/server-node/tsconfig.json @@ -3,7 +3,7 @@ // Uses "." so it can load package.json. "rootDir": ".", "outDir": "dist", - "target": "es6", + "target": "es2017", "lib": ["es6"], "module": "commonjs", "strict": true, diff --git a/packages/sdk/vercel/tsconfig.json b/packages/sdk/vercel/tsconfig.json index 2635087a0..39049b642 100644 --- a/packages/sdk/vercel/tsconfig.json +++ b/packages/sdk/vercel/tsconfig.json @@ -3,7 +3,7 @@ // Uses "." so it can load package.json. "rootDir": ".", "outDir": "dist", - "target": "es6", + "target": "es2017", "lib": ["es6"], "module": "commonjs", "strict": true, diff --git a/packages/shared/akamai-edgeworker-sdk/tsconfig.json b/packages/shared/akamai-edgeworker-sdk/tsconfig.json index 823c9c67d..def6a5c83 100644 --- a/packages/shared/akamai-edgeworker-sdk/tsconfig.json +++ b/packages/shared/akamai-edgeworker-sdk/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", - "target": "es6", + "target": "es2017", "lib": ["es6"], "module": "commonjs", "strict": true, diff --git a/packages/shared/common/package.json b/packages/shared/common/package.json index feba16f5e..5c8e3c376 100644 --- a/packages/shared/common/package.json +++ b/packages/shared/common/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/js-sdk-common", - "version": "1.0.2", + "version": "1.1.0-beta.1", "type": "commonjs", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/shared/common/tsconfig.json b/packages/shared/common/tsconfig.json index 19e8b1e59..cd3f7af3c 100644 --- a/packages/shared/common/tsconfig.json +++ b/packages/shared/common/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", - "target": "es6", + "target": "es2017", "lib": ["es6"], "module": "commonjs", "strict": true, diff --git a/packages/shared/sdk-server-edge/tsconfig.json b/packages/shared/sdk-server-edge/tsconfig.json index 4b0f39132..a34abfe7c 100644 --- a/packages/shared/sdk-server-edge/tsconfig.json +++ b/packages/shared/sdk-server-edge/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "rootDir": ".", "outDir": "dist", - "target": "es6", + "target": "es2017", "lib": ["es6", "webworker"], "module": "commonjs", "strict": true, diff --git a/packages/shared/sdk-server/package.json b/packages/shared/sdk-server/package.json index 446bca23c..78796ddb9 100644 --- a/packages/shared/sdk-server/package.json +++ b/packages/shared/sdk-server/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/js-server-sdk-common", - "version": "1.0.7", + "version": "1.1.0-beta.1", "type": "commonjs", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -28,7 +28,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@launchdarkly/js-sdk-common": "1.0.2", + "@launchdarkly/js-sdk-common": "1.1.0-beta.1", "semver": "7.5.4" }, "devDependencies": { diff --git a/packages/shared/sdk-server/tsconfig.json b/packages/shared/sdk-server/tsconfig.json index 19e8b1e59..cd3f7af3c 100644 --- a/packages/shared/sdk-server/tsconfig.json +++ b/packages/shared/sdk-server/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", - "target": "es6", + "target": "es2017", "lib": ["es6"], "module": "commonjs", "strict": true, diff --git a/packages/store/node-server-sdk-dynamodb/tsconfig.json b/packages/store/node-server-sdk-dynamodb/tsconfig.json index 1b9ac79b8..e4158b054 100644 --- a/packages/store/node-server-sdk-dynamodb/tsconfig.json +++ b/packages/store/node-server-sdk-dynamodb/tsconfig.json @@ -3,7 +3,7 @@ // Uses "." so it can load package.json. "rootDir": ".", "outDir": "dist", - "target": "es6", + "target": "es2017", "lib": ["es6"], "module": "commonjs", "strict": true, diff --git a/packages/store/node-server-sdk-redis/tsconfig.json b/packages/store/node-server-sdk-redis/tsconfig.json index 1b9ac79b8..e4158b054 100644 --- a/packages/store/node-server-sdk-redis/tsconfig.json +++ b/packages/store/node-server-sdk-redis/tsconfig.json @@ -3,7 +3,7 @@ // Uses "." so it can load package.json. "rootDir": ".", "outDir": "dist", - "target": "es6", + "target": "es2017", "lib": ["es6"], "module": "commonjs", "strict": true, From fca0b27a850a5f6318808cb3a77551e69112f08d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 10 Aug 2023 12:53:37 -0700 Subject: [PATCH 03/19] Remove pre-release versions. --- packages/sdk/server-node/package.json | 4 ++-- packages/shared/common/package.json | 2 +- packages/shared/sdk-server/package.json | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/sdk/server-node/package.json b/packages/sdk/server-node/package.json index 663d1a7d7..93ff4b1c6 100644 --- a/packages/sdk/server-node/package.json +++ b/packages/sdk/server-node/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/node-server-sdk", - "version": "8.2.0-beta.1", + "version": "8.1.1", "description": "LaunchDarkly Server-Side SDK for Node.js", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/server-node", "repository": { @@ -46,7 +46,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@launchdarkly/js-server-sdk-common": "1.1.0-beta.1", + "@launchdarkly/js-server-sdk-common": "1.0.7", "https-proxy-agent": "^5.0.1", "launchdarkly-eventsource": "2.0.0" }, diff --git a/packages/shared/common/package.json b/packages/shared/common/package.json index 5c8e3c376..feba16f5e 100644 --- a/packages/shared/common/package.json +++ b/packages/shared/common/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/js-sdk-common", - "version": "1.1.0-beta.1", + "version": "1.0.2", "type": "commonjs", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/shared/sdk-server/package.json b/packages/shared/sdk-server/package.json index 78796ddb9..446bca23c 100644 --- a/packages/shared/sdk-server/package.json +++ b/packages/shared/sdk-server/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/js-server-sdk-common", - "version": "1.1.0-beta.1", + "version": "1.0.7", "type": "commonjs", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -28,7 +28,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@launchdarkly/js-sdk-common": "1.1.0-beta.1", + "@launchdarkly/js-sdk-common": "1.0.2", "semver": "7.5.4" }, "devDependencies": { From 83b7edee9fc5f0af621a727985274068e755fa1d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 10 Aug 2023 15:10:09 -0700 Subject: [PATCH 04/19] Bug fixes. --- .../shared/sdk-server/src/LDClientImpl.ts | 6 +-- .../sdk-server/src/evaluation/Evaluator.ts | 3 ++ .../sdk-server/src/evaluation/collection.ts | 40 ++++++++++++------- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 00bfab52f..6d83e3737 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -306,10 +306,10 @@ export default class LDClientImpl implements LDClient { return new Promise((resolve) => { allSeriesAsync( Object.values(allFlags), - async (storeItem) => { + async (storeItem, _index, innerCB) => { const flag = storeItem as Flag; if (clientOnly && !flag.clientSide) { - return true; + innerCB(true); } const res = await this.evaluator.evaluate(flag, evalContext); if (res.isError) { @@ -330,7 +330,7 @@ export default class LDClientImpl implements LDClient { detailsOnlyIfTracked, ); - return true; + innerCB(true); }, () => { const res = builder.build(); diff --git a/packages/shared/sdk-server/src/evaluation/Evaluator.ts b/packages/shared/sdk-server/src/evaluation/Evaluator.ts index b7bf6de89..e5001c07d 100644 --- a/packages/shared/sdk-server/src/evaluation/Evaluator.ts +++ b/packages/shared/sdk-server/src/evaluation/Evaluator.ts @@ -143,6 +143,7 @@ export default class Evaluator { ): void { if (!flag.on) { cb(getOffVariation(flag, Reasons.Off)); + return; } this.checkPrerequisites( @@ -317,7 +318,9 @@ export default class Evaluator { errorResult = res.result; } innerCB(res.error || res.isMatch); + // innerCB(true); }); + } else { innerCB(false); } }, diff --git a/packages/shared/sdk-server/src/evaluation/collection.ts b/packages/shared/sdk-server/src/evaluation/collection.ts index afb962523..babed618f 100644 --- a/packages/shared/sdk-server/src/evaluation/collection.ts +++ b/packages/shared/sdk-server/src/evaluation/collection.ts @@ -24,27 +24,37 @@ function seriesAsync( all: boolean, index: number, cb: (res: boolean) => void, + depth: number = 0, ): void { if (!collection) { cb(false); return; } - const item = collection[index]; - const resHandler = (res: boolean) => { - if (all) { - if (!res) { - return cb(false); + if (!collection.length) { + return; + } + if (index < collection?.length) { + check(collection[index], index, (res) => { + if (all) { + if (!res) { + cb(false); + return; + } + } else if (res) { + cb(true); + return; } - } else if (res) { - return cb(true); - } - const nextIndex = index + 1; - if (nextIndex < collection?.length) { - seriesAsync(collection, check, all, nextIndex, resHandler); - } - return cb(true); - }; - check(item, index, resHandler); + if (depth > 50) { + setImmediate(() => { + seriesAsync(collection, check, all, index + 1, cb, 0); + }); + } else { + seriesAsync(collection, check, all, index + 1, cb, depth + 1); + } + }); + } else { + cb(true); + } } /** From 40559335bdb0cbb29d542e23708a847235d41468 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 10 Aug 2023 15:37:08 -0700 Subject: [PATCH 05/19] Basic functionality working with eval callbacks. --- packages/shared/sdk-server/src/LDClientImpl.ts | 1 + packages/shared/sdk-server/src/evaluation/collection.ts | 7 ++----- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 6d83e3737..01d574923 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -310,6 +310,7 @@ export default class LDClientImpl implements LDClient { const flag = storeItem as Flag; if (clientOnly && !flag.clientSide) { innerCB(true); + return; } const res = await this.evaluator.evaluate(flag, evalContext); if (res.isError) { diff --git a/packages/shared/sdk-server/src/evaluation/collection.ts b/packages/shared/sdk-server/src/evaluation/collection.ts index babed618f..d634d465e 100644 --- a/packages/shared/sdk-server/src/evaluation/collection.ts +++ b/packages/shared/sdk-server/src/evaluation/collection.ts @@ -26,13 +26,10 @@ function seriesAsync( cb: (res: boolean) => void, depth: number = 0, ): void { - if (!collection) { + if (!collection || !collection.length) { cb(false); return; } - if (!collection.length) { - return; - } if (index < collection?.length) { check(collection[index], index, (res) => { if (all) { @@ -53,7 +50,7 @@ function seriesAsync( } }); } else { - cb(true); + cb(all); } } From 96878e3ff5132329dff3f2b56ddd8f5da471edc5 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 10 Aug 2023 16:01:03 -0700 Subject: [PATCH 06/19] Async data access. --- .../evaluation/Evaluator.segments.test.ts | 10 +- .../shared/sdk-server/src/LDClientImpl.ts | 106 +++++++------ .../sdk-server/src/evaluation/Evaluator.ts | 139 +++++++++--------- .../sdk-server/src/evaluation/Queries.ts | 4 +- 4 files changed, 137 insertions(+), 122 deletions(-) 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 5f94defa9..c74ad577b 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 01d574923..0e4907343 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -12,7 +12,14 @@ import { subsystem, } from '@launchdarkly/js-sdk-common'; -import { LDClient, LDFlagsState, LDFlagsStateOptions, LDOptions, LDStreamProcessor } from './api'; +import { + LDClient, + LDFeatureStore, + LDFlagsState, + LDFlagsStateOptions, + LDOptions, + LDStreamProcessor, +} from './api'; import { BigSegmentStoreMembership } from './api/interfaces'; import BigSegmentsManager from './BigSegmentsManager'; import BigSegmentStoreStatusProvider from './BigSegmentStatusProviderImpl'; @@ -38,7 +45,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 +71,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 +134,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 +176,9 @@ export default class LDClientImpl implements LDClient { ); } - const asyncFacade = new AsyncStoreFacade(featureStore); + // const asyncFacade = new AsyncStoreFacade(featureStore); - this.featureStore = asyncFacade; + this.featureStore = featureStore; const manager = new BigSegmentsManager( config.bigSegments?.store?.(clientContext), @@ -180,11 +190,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, @@ -282,7 +292,8 @@ export default class LDClientImpl implements LDClient { let valid = true; if (!this.initialized()) { - const storeInitialized = await this.featureStore.initialized(); + const storeInitialized = await this.asyncFeatureStore.initialized(); + if (storeInitialized) { this.logger?.warn( 'Called allFlagsState before client initialization; using last known' + @@ -301,44 +312,43 @@ export default class LDClientImpl implements LDClient { const clientOnly = !!options?.clientSideOnly; const detailsOnlyIfTracked = !!options?.detailsOnlyForTrackedFlags; - const allFlags = await this.featureStore.all(VersionedDataKinds.Features); - return new Promise((resolve) => { - allSeriesAsync( - Object.values(allFlags), - async (storeItem, _index, innerCB) => { - const flag = storeItem as Flag; - if (clientOnly && !flag.clientSide) { - innerCB(true); - return; - } - 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}`, - ), + this.featureStore.all(VersionedDataKinds.Features, (allFlags) => { + allSeriesAsync( + Object.values(allFlags), + async (storeItem, _index, innerCB) => { + const flag = storeItem as Flag; + if (clientOnly && !flag.clientSide) { + innerCB(true); + return; + } + 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}`, + ), + ); + } + 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, ); - } - 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, - ); - - innerCB(true); - }, - () => { - const res = builder.build(); - callback?.(null, res); - resolve(res); - }, - ); + innerCB(true); + }, + () => { + const res = builder.build(); + callback?.(null, res); + resolve(res); + }, + ); + }); }); } @@ -413,7 +423,7 @@ export default class LDClientImpl implements LDClient { return EvalResult.forError(ErrorKinds.UserNotSpecified, undefined, defaultValue); } - const flag = (await this.featureStore.get(VersionedDataKinds.Features, flagKey)) as Flag; + const flag = (await this.asyncFeatureStore.get(VersionedDataKinds.Features, flagKey)) as Flag; if (!flag) { const error = new LDClientError(`Unknown feature flag "${flagKey}"; returning default value`); this.onError(error); @@ -444,7 +454,7 @@ export default class LDClientImpl implements LDClient { eventFactory: EventFactory, ): Promise { if (!this.initialized()) { - const storeInitialized = await this.featureStore.initialized(); + const storeInitialized = await this.asyncFeatureStore.initialized(); if (storeInitialized) { this.logger?.warn( 'Variation called before LaunchDarkly client initialization completed' + diff --git a/packages/shared/sdk-server/src/evaluation/Evaluator.ts b/packages/shared/sdk-server/src/evaluation/Evaluator.ts index e5001c07d..2e29cbbea 100644 --- a/packages/shared/sdk-server/src/evaluation/Evaluator.ts +++ b/packages/shared/sdk-server/src/evaluation/Evaluator.ts @@ -107,6 +107,7 @@ export default class Evaluator { flag, context, state, + [], (res) => { if (state.bigSegmentsStatus) { res.detail.reason = { @@ -137,7 +138,7 @@ export default class Evaluator { context: Context, // eslint-disable-next-line @typescript-eslint/no-unused-vars state: EvalState, - // visitedFlags: string[], + visitedFlags: string[], cb: (res: EvalResult) => void, eventFactory?: EventFactory, ): void { @@ -150,7 +151,7 @@ export default class Evaluator { flag, context, state, - // visitedFlags, + visitedFlags, (res) => { // If there is a prereq result, then prereqs have failed, or there was // an error. @@ -191,7 +192,7 @@ export default class Evaluator { flag: Flag, context: Context, state: EvalState, - // visitedFlags: string[], + visitedFlags: string[], cb: (res: EvalResult | undefined) => void, eventFactory?: EventFactory, ): void { @@ -206,52 +207,53 @@ export default class Evaluator { // the result of the series evaluation. allSeriesAsync( flag.prerequisites, - async (prereq, _index, prereq_cb) => { - // 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)); - prereq_cb(false); + (prereq, _index, prereq_cb) => { + 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.', + ); + prereq_cb(true); return; } + const updatedVisitedFlags = [...visitedFlags, prereq.key]; + this.queries.getFlag(prereq.key, (prereqFlag) => { + if (!prereqFlag) { + prereqResult = getOffVariation(flag, Reasons.prerequisiteFailed(prereq.key)); + prereq_cb(false); + return; + } - 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), - ); - } + 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 prereq_cb(false); - } + if (res.isError) { + prereqResult = res; + return prereq_cb(false); + } - if (res.isOff || res.detail.variationIndex !== prereq.variation) { - prereqResult = getOffVariation(flag, Reasons.prerequisiteFailed(prereq.key)); - return prereq_cb(false); - } - return prereq_cb(true); - }, - eventFactory, - ); + if (res.isOff || res.detail.variationIndex !== prereq.variation) { + prereqResult = getOffVariation(flag, Reasons.prerequisiteFailed(prereq.key)); + return prereq_cb(false); + } + return prereq_cb(true); + }, + eventFactory, + ); + }); }, () => { cb(prereqResult); @@ -298,31 +300,32 @@ export default class Evaluator { if (clause.op === 'segmentMatch') { firstSeriesAsync( clause.values, - async (value, _index, innerCB) => { - 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. - innerCB(true); - return; - } - - const newVisited = [...segmentsVisited, segment?.key]; - this.segmentMatchContext(segment, context, state, newVisited, (res) => { - if (res.error) { - errorResult = res.result; + (value, _index, innerCB) => { + 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. + innerCB(true); + return; } - innerCB(res.error || res.isMatch); - // innerCB(true); - }); - } else { - innerCB(false); - } + + const newVisited = [...segmentsVisited, segment?.key]; + this.segmentMatchContext(segment, context, state, newVisited, (res) => { + if (res.error) { + errorResult = res.result; + } + innerCB(res.error || res.isMatch); + // innerCB(true); + }); + } else { + innerCB(false); + } + }); }, (match) => { if (errorResult) { diff --git a/packages/shared/sdk-server/src/evaluation/Queries.ts b/packages/shared/sdk-server/src/evaluation/Queries.ts index 4bcd2677d..9737f1848 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>; From 33a982f24643d11c867dc997d48c3ed0387f6199 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 10 Aug 2023 16:31:16 -0700 Subject: [PATCH 07/19] Conversion mostly complete. --- .../shared/sdk-server/src/LDClientImpl.ts | 159 ++++++++++-------- .../sdk-server/src/evaluation/Evaluator.ts | 26 +++ 2 files changed, 115 insertions(+), 70 deletions(-) diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 0e4907343..cffa97775 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -248,12 +248,12 @@ export default class LDClientImpl implements LDClient { 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.eventFactoryWithReasons, (res) => { + resolve(res.detail.value); + callback?.(null, res.detail.value); + }); + }); } async variationDetail( @@ -262,14 +262,12 @@ export default class LDClientImpl implements LDClient { 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( @@ -322,25 +320,26 @@ export default class LDClientImpl implements LDClient { innerCB(true); return; } - 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}`, - ), + 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, ); - } - 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, - ); - innerCB(true); + innerCB(true); + }); }, () => { const res = builder.build(); @@ -403,15 +402,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) { @@ -420,54 +421,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.asyncFeatureStore.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.asyncFeatureStore.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 2e29cbbea..77d9d9151 100644 --- a/packages/shared/sdk-server/src/evaluation/Evaluator.ts +++ b/packages/shared/sdk-server/src/evaluation/Evaluator.ts @@ -123,6 +123,32 @@ export default class Evaluator { }); } + evaluateCb( + flag: Flag, + context: Context, + cb: (res: EvalResult) => void, + eventFactory?: EventFactory, + ) { + const state = new 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, + ); + } + /** * Evaluate the given flag against the given context. This internal method is entered * initially from the external evaluation method, but may be recursively executed during From c33671236500f86d6f8332d7647ce7a73c8ad684 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 10 Aug 2023 18:48:25 -0700 Subject: [PATCH 08/19] Debugging --- .../__tests__/evaluation/Evaluator.test.ts | 242 ++++++++++++++++++ .../sdk-server/src/evaluation/Evaluator.ts | 1 + .../sdk-server/src/evaluation/collection.ts | 11 +- 3 files changed, 251 insertions(+), 3 deletions(-) diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts index 3a39e66e5..6d6b3cff2 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts @@ -1,6 +1,8 @@ import { Context, LDContext } from '@launchdarkly/js-sdk-common'; +import { deserializePoll } from '../../src'; import { Flag } from '../../src/evaluation/data/Flag'; +import { Segment } from '../../src/evaluation/data/Segment'; import EvalResult from '../../src/evaluation/EvalResult'; import Evaluator from '../../src/evaluation/Evaluator'; import Reasons from '../../src/evaluation/Reasons'; @@ -15,6 +17,246 @@ const offBaseFlag = { variations: ['zero', 'one', 'two'], }; +const testData = { + data: { + flags: { + 'flag-using-segment': { + key: 'flag-using-segment', + on: true, + prerequisites: [], + targets: [], + contextTargets: [], + rules: [ + { + variation: 0, + id: 'ruleid', + clauses: [ + { + attribute: '', + op: 'segmentMatch', + values: ['segment-requiring-either-of-two-segments'], + negate: false, + }, + ], + trackEvents: false, + }, + ], + fallthrough: { variation: 1 }, + offVariation: 1, + variations: [true, false], + clientSide: false, + salt: '', + trackEvents: false, + trackEventsFallthrough: false, + debugEventsUntilDate: null, + version: 1, + deleted: false, + }, + }, + segments: { + 'recursive-segment-1': { + key: 'recursive-segment-1', + included: [], + excluded: [], + includedContexts: [], + excludedContexts: [], + salt: '', + rules: [ + { + id: '', + clauses: [ + { + attribute: '', + op: 'segmentMatch', + values: ['\u003cRECURSIVE_SEGMENT_1_USES\u003e'], + negate: false, + }, + ], + }, + ], + version: 1, + generation: null, + deleted: false, + }, + 'recursive-segment-2': { + key: 'recursive-segment-2', + included: [], + excluded: [], + includedContexts: [], + excludedContexts: [], + salt: '', + rules: [ + { + id: '', + clauses: [ + { + attribute: '', + op: 'segmentMatch', + values: ['\u003cRECURSIVE_SEGMENT_2_USES\u003e'], + negate: false, + }, + ], + }, + ], + version: 1, + generation: null, + deleted: false, + }, + 'recursive-segment-3': { + key: 'recursive-segment-3', + included: [], + excluded: [], + includedContexts: [], + excludedContexts: [], + salt: '', + rules: [ + { + id: '', + clauses: [ + { + attribute: '', + op: 'segmentMatch', + values: ['\u003cRECURSIVE_SEGMENT_3_USES\u003e'], + negate: false, + }, + ], + }, + ], + version: 1, + generation: null, + deleted: false, + }, + 'segment-requiring-both-of-two-segments': { + key: 'segment-requiring-both-of-two-segments', + included: [], + excluded: [], + includedContexts: [], + excludedContexts: [], + salt: '', + rules: [ + { + id: '', + clauses: [ + { attribute: '', op: 'segmentMatch', values: ['segment-with-rule-a'], negate: false }, + { attribute: '', op: 'segmentMatch', values: ['segment-with-rule-b'], negate: false }, + ], + }, + ], + version: 1, + generation: null, + deleted: false, + }, + 'segment-requiring-either-of-two-segments': { + key: 'segment-requiring-either-of-two-segments', + included: [], + excluded: [], + includedContexts: [], + excludedContexts: [], + salt: '', + rules: [ + { + id: '', + clauses: [ + { attribute: '', op: 'segmentMatch', values: ['segment-with-rule-a'], negate: false }, + ], + }, + { id: '', clauses: [] }, + ], + version: 1, + generation: null, + deleted: false, + }, + 'segment-that-always-matches': { + key: 'segment-that-always-matches', + included: [], + excluded: [], + includedContexts: [], + excludedContexts: [], + salt: '', + rules: [{ id: '', clauses: [{ attribute: 'key', op: 'in', values: [''], negate: true }] }], + version: 1, + generation: null, + deleted: false, + }, + 'segment-with-rule-a': { + key: 'segment-with-rule-a', + included: [], + excluded: [], + includedContexts: [], + excludedContexts: [], + salt: '', + rules: [ + { + id: '', + clauses: [ + { + attribute: 'segment-with-rule-a-should-match', + op: 'in', + values: [true], + negate: false, + }, + ], + }, + ], + version: 1, + generation: null, + deleted: false, + }, + 'segment-with-rule-b': { + key: 'segment-with-rule-b', + included: [], + excluded: [], + includedContexts: [], + excludedContexts: [], + salt: '', + rules: [ + { + id: '', + clauses: [ + { + attribute: 'segment-with-rule-b-should-match', + op: 'in', + values: [true], + negate: false, + }, + ], + }, + ], + version: 1, + generation: null, + deleted: false, + }, + }, + }, +}; +// DEBUG[2023 -08 - 10 18:01: 22.002] Sending command: { "command": "evaluateAll", "evaluate": null, "evaluateAll": { "context": { "kind": "user", "key": "user-key", "segment-with-rule-a-should-match": false, "segment-with-rule-b-should-match": true }, "withReasons": false, "clientSideOnly": false, "detailsOnlyForTrackedFlags": false }, "customEvent": null, "identifyEvent": null, "contextBuild": null, "contextConvert": null, "secureModeHash": null }; + +it('evaluates this stupid data', async () => { + const evalData = deserializePoll(JSON.stringify(testData)); + const evaluator = new Evaluator(basicPlatform, { + getFlag(key, cb) { + cb(evalData!.flags[key] as Flag); + }, + getSegment(key, cb) { + cb(evalData!.segments[key] as Segment); + }, + async getBigSegmentsMembership() { + return undefined; + }, + }); + + const result = await evaluator.evaluate( + evalData!.flags['flag-using-segment'], + Context.fromLDContext({ + kind: 'user', + key: 'user-key', + 'segment-with-rule-b-should-match': true, + 'segment-with-rule-a-should-match': false, + }), + ); + expect(result.detail.value).toBeTruthy(); +}); + describe.each<[Flag, LDContext, EvalResult | undefined]>([ [ { diff --git a/packages/shared/sdk-server/src/evaluation/Evaluator.ts b/packages/shared/sdk-server/src/evaluation/Evaluator.ts index 77d9d9151..ece875e72 100644 --- a/packages/shared/sdk-server/src/evaluation/Evaluator.ts +++ b/packages/shared/sdk-server/src/evaluation/Evaluator.ts @@ -395,6 +395,7 @@ export default class Evaluator { cb: (res: EvalResult | undefined) => void, ): void { if (!rule.clauses) { + cb(undefined); return; } let errorResult: EvalResult | undefined; diff --git a/packages/shared/sdk-server/src/evaluation/collection.ts b/packages/shared/sdk-server/src/evaluation/collection.ts index d634d465e..effa2b1dd 100644 --- a/packages/shared/sdk-server/src/evaluation/collection.ts +++ b/packages/shared/sdk-server/src/evaluation/collection.ts @@ -18,6 +18,8 @@ export function firstResult( return res; } +const ITERATION_RECURSION_LIMIT = 50; + function seriesAsync( collection: T[] | undefined, check: (val: T, index: number, cb: (res: boolean) => void) => void, @@ -26,7 +28,7 @@ function seriesAsync( cb: (res: boolean) => void, depth: number = 0, ): void { - if (!collection || !collection.length) { + if (!collection) { cb(false); return; } @@ -41,8 +43,11 @@ function seriesAsync( cb(true); return; } - if (depth > 50) { - setImmediate(() => { + if (depth > 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, 0); }); } else { From 7d98812002a67429e6a5a0f944e0f20c9d5480f3 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 10 Aug 2023 19:38:32 -0700 Subject: [PATCH 09/19] Change recursion limit. --- packages/shared/sdk-server/src/LDClientImpl.ts | 2 +- packages/shared/sdk-server/src/evaluation/Evaluator.ts | 1 + packages/shared/sdk-server/src/evaluation/collection.ts | 7 +++---- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index cffa97775..d429435f2 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -249,7 +249,7 @@ export default class LDClientImpl implements LDClient { callback?: (err: any, res: any) => void, ): Promise { return new Promise((resolve) => { - this.evaluateIfPossible(key, context, defaultValue, this.eventFactoryWithReasons, (res) => { + this.evaluateIfPossible(key, context, defaultValue, this.eventFactoryDefault, (res) => { resolve(res.detail.value); callback?.(null, res.detail.value); }); diff --git a/packages/shared/sdk-server/src/evaluation/Evaluator.ts b/packages/shared/sdk-server/src/evaluation/Evaluator.ts index ece875e72..e4fd91a98 100644 --- a/packages/shared/sdk-server/src/evaluation/Evaluator.ts +++ b/packages/shared/sdk-server/src/evaluation/Evaluator.ts @@ -632,6 +632,7 @@ export default class Evaluator { context, state, ).then(cb); + return; } this.queries.getBigSegmentsMembership(keyForBigSegment).then((result) => { diff --git a/packages/shared/sdk-server/src/evaluation/collection.ts b/packages/shared/sdk-server/src/evaluation/collection.ts index effa2b1dd..37904ecbe 100644 --- a/packages/shared/sdk-server/src/evaluation/collection.ts +++ b/packages/shared/sdk-server/src/evaluation/collection.ts @@ -26,7 +26,6 @@ function seriesAsync( all: boolean, index: number, cb: (res: boolean) => void, - depth: number = 0, ): void { if (!collection) { cb(false); @@ -43,15 +42,15 @@ function seriesAsync( cb(true); return; } - if (depth > ITERATION_RECURSION_LIMIT) { + 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, 0); + seriesAsync(collection, check, all, index + 1, cb); }); } else { - seriesAsync(collection, check, all, index + 1, cb, depth + 1); + seriesAsync(collection, check, all, index + 1, cb); } }); } else { From 33617dc16c4bb55f4729fff650a2bcea7ce1d7da Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 11 Aug 2023 08:32:33 -0700 Subject: [PATCH 10/19] Remove test from debugging. --- .../__tests__/evaluation/Evaluator.test.ts | 240 ------------------ 1 file changed, 240 deletions(-) diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts index 6d6b3cff2..94ab1c9c2 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts @@ -17,246 +17,6 @@ const offBaseFlag = { variations: ['zero', 'one', 'two'], }; -const testData = { - data: { - flags: { - 'flag-using-segment': { - key: 'flag-using-segment', - on: true, - prerequisites: [], - targets: [], - contextTargets: [], - rules: [ - { - variation: 0, - id: 'ruleid', - clauses: [ - { - attribute: '', - op: 'segmentMatch', - values: ['segment-requiring-either-of-two-segments'], - negate: false, - }, - ], - trackEvents: false, - }, - ], - fallthrough: { variation: 1 }, - offVariation: 1, - variations: [true, false], - clientSide: false, - salt: '', - trackEvents: false, - trackEventsFallthrough: false, - debugEventsUntilDate: null, - version: 1, - deleted: false, - }, - }, - segments: { - 'recursive-segment-1': { - key: 'recursive-segment-1', - included: [], - excluded: [], - includedContexts: [], - excludedContexts: [], - salt: '', - rules: [ - { - id: '', - clauses: [ - { - attribute: '', - op: 'segmentMatch', - values: ['\u003cRECURSIVE_SEGMENT_1_USES\u003e'], - negate: false, - }, - ], - }, - ], - version: 1, - generation: null, - deleted: false, - }, - 'recursive-segment-2': { - key: 'recursive-segment-2', - included: [], - excluded: [], - includedContexts: [], - excludedContexts: [], - salt: '', - rules: [ - { - id: '', - clauses: [ - { - attribute: '', - op: 'segmentMatch', - values: ['\u003cRECURSIVE_SEGMENT_2_USES\u003e'], - negate: false, - }, - ], - }, - ], - version: 1, - generation: null, - deleted: false, - }, - 'recursive-segment-3': { - key: 'recursive-segment-3', - included: [], - excluded: [], - includedContexts: [], - excludedContexts: [], - salt: '', - rules: [ - { - id: '', - clauses: [ - { - attribute: '', - op: 'segmentMatch', - values: ['\u003cRECURSIVE_SEGMENT_3_USES\u003e'], - negate: false, - }, - ], - }, - ], - version: 1, - generation: null, - deleted: false, - }, - 'segment-requiring-both-of-two-segments': { - key: 'segment-requiring-both-of-two-segments', - included: [], - excluded: [], - includedContexts: [], - excludedContexts: [], - salt: '', - rules: [ - { - id: '', - clauses: [ - { attribute: '', op: 'segmentMatch', values: ['segment-with-rule-a'], negate: false }, - { attribute: '', op: 'segmentMatch', values: ['segment-with-rule-b'], negate: false }, - ], - }, - ], - version: 1, - generation: null, - deleted: false, - }, - 'segment-requiring-either-of-two-segments': { - key: 'segment-requiring-either-of-two-segments', - included: [], - excluded: [], - includedContexts: [], - excludedContexts: [], - salt: '', - rules: [ - { - id: '', - clauses: [ - { attribute: '', op: 'segmentMatch', values: ['segment-with-rule-a'], negate: false }, - ], - }, - { id: '', clauses: [] }, - ], - version: 1, - generation: null, - deleted: false, - }, - 'segment-that-always-matches': { - key: 'segment-that-always-matches', - included: [], - excluded: [], - includedContexts: [], - excludedContexts: [], - salt: '', - rules: [{ id: '', clauses: [{ attribute: 'key', op: 'in', values: [''], negate: true }] }], - version: 1, - generation: null, - deleted: false, - }, - 'segment-with-rule-a': { - key: 'segment-with-rule-a', - included: [], - excluded: [], - includedContexts: [], - excludedContexts: [], - salt: '', - rules: [ - { - id: '', - clauses: [ - { - attribute: 'segment-with-rule-a-should-match', - op: 'in', - values: [true], - negate: false, - }, - ], - }, - ], - version: 1, - generation: null, - deleted: false, - }, - 'segment-with-rule-b': { - key: 'segment-with-rule-b', - included: [], - excluded: [], - includedContexts: [], - excludedContexts: [], - salt: '', - rules: [ - { - id: '', - clauses: [ - { - attribute: 'segment-with-rule-b-should-match', - op: 'in', - values: [true], - negate: false, - }, - ], - }, - ], - version: 1, - generation: null, - deleted: false, - }, - }, - }, -}; -// DEBUG[2023 -08 - 10 18:01: 22.002] Sending command: { "command": "evaluateAll", "evaluate": null, "evaluateAll": { "context": { "kind": "user", "key": "user-key", "segment-with-rule-a-should-match": false, "segment-with-rule-b-should-match": true }, "withReasons": false, "clientSideOnly": false, "detailsOnlyForTrackedFlags": false }, "customEvent": null, "identifyEvent": null, "contextBuild": null, "contextConvert": null, "secureModeHash": null }; - -it('evaluates this stupid data', async () => { - const evalData = deserializePoll(JSON.stringify(testData)); - const evaluator = new Evaluator(basicPlatform, { - getFlag(key, cb) { - cb(evalData!.flags[key] as Flag); - }, - getSegment(key, cb) { - cb(evalData!.segments[key] as Segment); - }, - async getBigSegmentsMembership() { - return undefined; - }, - }); - - const result = await evaluator.evaluate( - evalData!.flags['flag-using-segment'], - Context.fromLDContext({ - kind: 'user', - key: 'user-key', - 'segment-with-rule-b-should-match': true, - 'segment-with-rule-a-should-match': false, - }), - ); - expect(result.detail.value).toBeTruthy(); -}); - describe.each<[Flag, LDContext, EvalResult | undefined]>([ [ { From 26d0ede4c8ac7c5a036353a56715de2451e31b69 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 11 Aug 2023 08:33:44 -0700 Subject: [PATCH 11/19] Cleanup. --- .../shared/sdk-server/__tests__/evaluation/Evaluator.test.ts | 2 -- packages/shared/sdk-server/src/LDClientImpl.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts index 94ab1c9c2..3a39e66e5 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts @@ -1,8 +1,6 @@ import { Context, LDContext } from '@launchdarkly/js-sdk-common'; -import { deserializePoll } from '../../src'; import { Flag } from '../../src/evaluation/data/Flag'; -import { Segment } from '../../src/evaluation/data/Segment'; import EvalResult from '../../src/evaluation/EvalResult'; import Evaluator from '../../src/evaluation/Evaluator'; import Reasons from '../../src/evaluation/Reasons'; diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index d429435f2..43b401238 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -176,8 +176,6 @@ export default class LDClientImpl implements LDClient { ); } - // const asyncFacade = new AsyncStoreFacade(featureStore); - this.featureStore = featureStore; const manager = new BigSegmentsManager( From 17ff57af901448231aa7f7149242c40801d00a68 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 11 Aug 2023 09:22:42 -0700 Subject: [PATCH 12/19] Remove async from methods that return a promise directly. --- packages/shared/sdk-server/src/LDClientImpl.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 43b401238..cd52915c3 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -240,7 +240,7 @@ export default class LDClientImpl implements LDClient { return this.initializedPromise; } - async variation( + variation( key: string, context: LDContext, defaultValue: any, @@ -254,7 +254,7 @@ export default class LDClientImpl implements LDClient { }); } - async variationDetail( + variationDetail( key: string, context: LDContext, defaultValue: any, From 19ad6b9546436a07944b6922e755a4c5ff550ab5 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 11 Aug 2023 09:36:50 -0700 Subject: [PATCH 13/19] Style changes. --- .../sdk-server/src/evaluation/Evaluator.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/shared/sdk-server/src/evaluation/Evaluator.ts b/packages/shared/sdk-server/src/evaluation/Evaluator.ts index e4fd91a98..246c562cc 100644 --- a/packages/shared/sdk-server/src/evaluation/Evaluator.ts +++ b/packages/shared/sdk-server/src/evaluation/Evaluator.ts @@ -192,9 +192,9 @@ export default class Evaluator { return; } - this.evaluateRules(flag, context, state, (eval_res) => { - if (eval_res) { - cb(eval_res); + this.evaluateRules(flag, context, state, (evalRes) => { + if (evalRes) { + cb(evalRes); return; } @@ -233,21 +233,21 @@ export default class Evaluator { // the result of the series evaluation. allSeriesAsync( flag.prerequisites, - (prereq, _index, prereq_cb) => { + (prereq, _index, prereqCb) => { 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.', ); - prereq_cb(true); + prereqCb(true); return; } const updatedVisitedFlags = [...visitedFlags, prereq.key]; this.queries.getFlag(prereq.key, (prereqFlag) => { if (!prereqFlag) { prereqResult = getOffVariation(flag, Reasons.prerequisiteFailed(prereq.key)); - prereq_cb(false); + prereqCb(false); return; } @@ -268,14 +268,14 @@ export default class Evaluator { if (res.isError) { prereqResult = res; - return prereq_cb(false); + return prereqCb(false); } if (res.isOff || res.detail.variationIndex !== prereq.variation) { prereqResult = getOffVariation(flag, Reasons.prerequisiteFailed(prereq.key)); - return prereq_cb(false); + return prereqCb(false); } - return prereq_cb(true); + return prereqCb(true); }, eventFactory, ); @@ -326,7 +326,7 @@ export default class Evaluator { if (clause.op === 'segmentMatch') { firstSeriesAsync( clause.values, - (value, _index, innerCB) => { + (value, _index, innerCb) => { this.queries.getSegment(value, (segment) => { if (segment) { if (segmentsVisited.includes(segment.key)) { @@ -336,7 +336,7 @@ export default class Evaluator { 'This is probably a temporary condition due to an incomplete update', ); // There was an error, so stop checking further segments. - innerCB(true); + innerCb(true); return; } @@ -345,11 +345,11 @@ export default class Evaluator { if (res.error) { errorResult = res.result; } - innerCB(res.error || res.isMatch); - // innerCB(true); + innerCb(res.error || res.isMatch); + // innerCb(true); }); } else { - innerCB(false); + innerCb(false); } }); }, From 204de93a251b274be922e78f51102b569bd0836f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 11 Aug 2023 09:41:23 -0700 Subject: [PATCH 14/19] Fix callback name. --- packages/shared/sdk-server/src/evaluation/Evaluator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/sdk-server/src/evaluation/Evaluator.ts b/packages/shared/sdk-server/src/evaluation/Evaluator.ts index 246c562cc..952a306a2 100644 --- a/packages/shared/sdk-server/src/evaluation/Evaluator.ts +++ b/packages/shared/sdk-server/src/evaluation/Evaluator.ts @@ -401,10 +401,10 @@ export default class Evaluator { let errorResult: EvalResult | undefined; allSeriesAsync( rule.clauses, - (clause, _index, rule_cb) => { + (clause, _index, ruleCb) => { this.clauseMatchContext(clause, context, segmentsVisited, state, (res) => { errorResult = res.result; - return rule_cb(res.error || res.isMatch); + return ruleCb(res.error || res.isMatch); }); }, (match) => { From 7a3ef530540850a2bc9df2c4b632cc181def13ad Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 11 Aug 2023 09:49:10 -0700 Subject: [PATCH 15/19] Add documentation about callbacks. --- .../sdk-server/src/evaluation/Evaluator.ts | 18 +++++++++++++++--- .../sdk-server/src/evaluation/collection.ts | 6 +++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/shared/sdk-server/src/evaluation/Evaluator.ts b/packages/shared/sdk-server/src/evaluation/Evaluator.ts index 952a306a2..c3d272b28 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 = { @@ -211,7 +220,8 @@ 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 checkPrerequisites( @@ -293,7 +303,8 @@ 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 evaluateRules( flag: Flag, @@ -383,7 +394,8 @@ 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 ruleMatchContext( flag: Flag, diff --git a/packages/shared/sdk-server/src/evaluation/collection.ts b/packages/shared/sdk-server/src/evaluation/collection.ts index 37904ecbe..338ec6508 100644 --- a/packages/shared/sdk-server/src/evaluation/collection.ts +++ b/packages/shared/sdk-server/src/evaluation/collection.ts @@ -62,7 +62,7 @@ function seriesAsync( * 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 function allSeriesAsync( collection: T[] | undefined, @@ -76,8 +76,8 @@ export function allSeriesAsync( * 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, From 31e4cda45496ab6b655592930ec61f5f8067e3dc Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 11 Aug 2023 10:00:54 -0700 Subject: [PATCH 16/19] Better iterator callback name. --- .../shared/sdk-server/src/LDClientImpl.ts | 6 +-- .../sdk-server/src/evaluation/Evaluator.ts | 37 +++++++++---------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index cd52915c3..7fed128c0 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -312,10 +312,10 @@ export default class LDClientImpl implements LDClient { this.featureStore.all(VersionedDataKinds.Features, (allFlags) => { allSeriesAsync( Object.values(allFlags), - async (storeItem, _index, innerCB) => { + async (storeItem, _index, iterCb) => { const flag = storeItem as Flag; if (clientOnly && !flag.clientSide) { - innerCB(true); + iterCb(true); return; } this.evaluator.evaluateCb(flag, evalContext, (res) => { @@ -336,7 +336,7 @@ export default class LDClientImpl implements LDClient { requireExperimentData, detailsOnlyIfTracked, ); - innerCB(true); + iterCb(true); }); }, () => { diff --git a/packages/shared/sdk-server/src/evaluation/Evaluator.ts b/packages/shared/sdk-server/src/evaluation/Evaluator.ts index c3d272b28..383f31744 100644 --- a/packages/shared/sdk-server/src/evaluation/Evaluator.ts +++ b/packages/shared/sdk-server/src/evaluation/Evaluator.ts @@ -243,21 +243,21 @@ export default class Evaluator { // the result of the series evaluation. allSeriesAsync( flag.prerequisites, - (prereq, _index, prereqCb) => { + (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.', ); - prereqCb(true); + iterCb(true); return; } const updatedVisitedFlags = [...visitedFlags, prereq.key]; this.queries.getFlag(prereq.key, (prereqFlag) => { if (!prereqFlag) { prereqResult = getOffVariation(flag, Reasons.prerequisiteFailed(prereq.key)); - prereqCb(false); + iterCb(false); return; } @@ -278,14 +278,14 @@ export default class Evaluator { if (res.isError) { prereqResult = res; - return prereqCb(false); + return iterCb(false); } if (res.isOff || res.detail.variationIndex !== prereq.variation) { prereqResult = getOffVariation(flag, Reasons.prerequisiteFailed(prereq.key)); - return prereqCb(false); + return iterCb(false); } - return prereqCb(true); + return iterCb(true); }, eventFactory, ); @@ -316,10 +316,10 @@ export default class Evaluator { firstSeriesAsync( flag.rules, - (rule, ruleIndex, ruleCb: (res: boolean) => void) => { + (rule, ruleIndex, iterCb: (res: boolean) => void) => { this.ruleMatchContext(flag, rule, ruleIndex, context, state, [], (res) => { ruleResult = res; - ruleCb(!!res); + iterCb(!!res); }); }, () => cb(ruleResult), @@ -337,7 +337,7 @@ export default class Evaluator { if (clause.op === 'segmentMatch') { firstSeriesAsync( clause.values, - (value, _index, innerCb) => { + (value, _index, iterCb) => { this.queries.getSegment(value, (segment) => { if (segment) { if (segmentsVisited.includes(segment.key)) { @@ -347,7 +347,7 @@ export default class Evaluator { 'This is probably a temporary condition due to an incomplete update', ); // There was an error, so stop checking further segments. - innerCb(true); + iterCb(true); return; } @@ -356,11 +356,10 @@ export default class Evaluator { if (res.error) { errorResult = res.result; } - innerCb(res.error || res.isMatch); - // innerCb(true); + iterCb(res.error || res.isMatch); }); } else { - innerCb(false); + iterCb(false); } }); }, @@ -413,10 +412,10 @@ export default class Evaluator { let errorResult: EvalResult | undefined; allSeriesAsync( rule.clauses, - (clause, _index, ruleCb) => { + (clause, _index, iterCb) => { this.clauseMatchContext(clause, context, segmentsVisited, state, (res) => { errorResult = res.result; - return ruleCb(res.error || res.isMatch); + return iterCb(res.error || res.isMatch); }); }, (match) => { @@ -519,10 +518,10 @@ export default class Evaluator { let errorResult: EvalResult | undefined; allSeriesAsync( rule.clauses, - (clause, _index, innerCb) => { + (clause, _index, iterCb) => { this.clauseMatchContext(clause, context, segmentsVisited, state, (res) => { errorResult = res.result; - innerCb(res.error || res.isMatch); + iterCb(res.error || res.isMatch); }); }, (match) => { @@ -580,10 +579,10 @@ export default class Evaluator { let evalResult: EvalResult | undefined; firstSeriesAsync( segment.rules, - (rule, _index, innerCb) => { + (rule, _index, iterCb) => { this.segmentRuleMatchContext(segment, rule, context, state, segmentsVisited, (res) => { evalResult = res.result; - return innerCb(res.error || res.isMatch); + return iterCb(res.error || res.isMatch); }); }, (matched) => { From 6908265a38224dd58a669e80fe0f385d897245fe Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 14 Aug 2023 09:30:02 -0700 Subject: [PATCH 17/19] Do not use a class for Match/MatchError --- .../sdk-server/src/evaluation/Evaluator.ts | 60 ++++++++++--------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/packages/shared/sdk-server/src/evaluation/Evaluator.ts b/packages/shared/sdk-server/src/evaluation/Evaluator.ts index 383f31744..c1a426b8f 100644 --- a/packages/shared/sdk-server/src/evaluation/Evaluator.ts +++ b/packages/shared/sdk-server/src/evaluation/Evaluator.ts @@ -65,7 +65,7 @@ function computeUpdatedBigSegmentsStatus( return latest; } -class EvalState { +interface EvalState { events?: internal.InputEvalEvent[]; bigSegmentsStatus?: BigSegmentStoreStatusString; @@ -73,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 }; } /** @@ -111,7 +115,7 @@ export default class Evaluator { async evaluate(flag: Flag, context: Context, eventFactory?: EventFactory): Promise { return new Promise((resolve) => { - const state = new EvalState(); + const state: EvalState = {}; this.evaluateInternal( flag, context, @@ -138,7 +142,7 @@ export default class Evaluator { cb: (res: EvalResult) => void, eventFactory?: EventFactory, ) { - const state = new EvalState(); + const state: EvalState = {}; this.evaluateInternal( flag, context, @@ -365,10 +369,10 @@ export default class Evaluator { }, (match) => { if (errorResult) { - return cb(new MatchError(errorResult)); + return cb(makeError(errorResult)); } - return cb(new Match(match)); + return cb(makeMatch(match)); }, ); // TODO: Should this return here? @@ -377,14 +381,14 @@ export default class Evaluator { // This is after segment matching, which does not use the reference. if (!clause.attributeReference.isValid) { cb( - new MatchError( + makeError( EvalResult.forError(ErrorKinds.MalformedFlag, 'Invalid attribute reference in clause'), ), ); return; } - cb(new Match(matchClauseWithoutSegmentOperations(clause, context))); + cb(makeMatch(matchClauseWithoutSegmentOperations(clause, context))); } /** @@ -526,17 +530,17 @@ export default class Evaluator { }, (match) => { if (errorResult) { - return cb(new MatchError(errorResult)); + return cb(makeError(errorResult)); } if (match) { if (rule.weight === undefined) { - return cb(new Match(match)); + return cb(makeMatch(match)); } const bucketBy = getBucketBy(false, rule.bucketByAttributeReference); if (!bucketBy.isValid) { return cb( - new MatchError( + makeError( EvalResult.forError( ErrorKinds.MalformedFlag, 'Invalid attribute reference in clause', @@ -552,10 +556,10 @@ export default class Evaluator { segment.salt || '', rule.rolloutContextKind, ); - return cb(new Match(bucket < rule.weight / 100000.0)); + return cb(makeMatch(bucket < rule.weight / 100000.0)); } - return cb(new Match(false)); + return cb(makeMatch(false)); }, ); } @@ -571,7 +575,7 @@ export default class Evaluator { if (!segment.unbounded) { const includeExclude = matchSegmentTargets(segment, context); if (includeExclude !== undefined) { - cb(new Match(includeExclude)); + cb(makeMatch(includeExclude)); return; } } @@ -587,10 +591,10 @@ export default class Evaluator { }, (matched) => { if (evalResult) { - return cb(new MatchError(evalResult)); + return cb(makeError(evalResult)); } - return cb(new Match(matched)); + return cb(makeMatch(matched)); }, ); } @@ -613,7 +617,7 @@ export default class Evaluator { const keyForBigSegment = context.key(bigSegmentKind); if (!keyForBigSegment) { - cb(new Match(false)); + cb(makeMatch(false)); return; } @@ -627,7 +631,7 @@ export default class Evaluator { state.bigSegmentsStatus, 'NOT_CONFIGURED', ); - cb(new Match(false)); + cb(makeMatch(false)); return; } @@ -688,7 +692,7 @@ export default class Evaluator { // 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(new Match(included)); + resolve(makeMatch(included)); return; } this.simpleSegmentMatchContext(segment, context, state, [], resolve); From 8a15e150433e4d77e15b079091aec0b3cbe689ce Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 14 Aug 2023 13:38:02 -0700 Subject: [PATCH 18/19] Evaluate all flags concurrently. --- .../shared/sdk-server/src/LDClientImpl.ts | 130 +++++++++--------- .../sdk-server/src/evaluation/collection.ts | 31 +++++ 2 files changed, 99 insertions(+), 62 deletions(-) diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 7fed128c0..eb8411f2a 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -15,6 +15,7 @@ import { import { LDClient, LDFeatureStore, + LDFeatureStoreKindData, LDFlagsState, LDFlagsStateOptions, LDOptions, @@ -30,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'; @@ -268,7 +269,7 @@ export default class LDClientImpl implements LDClient { }); } - async allFlagsState( + allFlagsState( context: LDContext, options?: LDFlagsStateOptions, callback?: (err: Error | null, res: LDFlagsState) => void, @@ -277,75 +278,80 @@ 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.asyncFeatureStore.initialized(); - - 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; - } - } - - const builder = new FlagsStateBuilder(valid, !!options?.withReasons); - const clientOnly = !!options?.clientSideOnly; - const detailsOnlyIfTracked = !!options?.detailsOnlyForTrackedFlags; - return new Promise((resolve) => { - this.featureStore.all(VersionedDataKinds.Features, (allFlags) => { - allSeriesAsync( - Object.values(allFlags), - async (storeItem, _index, 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 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; } - 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); - }, - ); - }); + 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.featureStore.all(VersionedDataKinds.Features, (allFlags) => { + doEval(true); + }); + } }); } diff --git a/packages/shared/sdk-server/src/evaluation/collection.ts b/packages/shared/sdk-server/src/evaluation/collection.ts index 338ec6508..709acde95 100644 --- a/packages/shared/sdk-server/src/evaluation/collection.ts +++ b/packages/shared/sdk-server/src/evaluation/collection.ts @@ -86,3 +86,34 @@ export function firstSeriesAsync( ): 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 function allAsync( + collection: T[] | undefined, + 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)); + }); +} From 19c8a89cbc3082ddde3e39d9dca157a05291d43f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 18 Aug 2023 09:57:38 -0700 Subject: [PATCH 19/19] Remove double allFlags --- packages/shared/sdk-server/src/LDClientImpl.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index eb8411f2a..ab9f53300 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -348,9 +348,7 @@ export default class LDClientImpl implements LDClient { doEval(valid); }); } else { - this.featureStore.all(VersionedDataKinds.Features, (allFlags) => { - doEval(true); - }); + doEval(true); } }); }