Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion libs/providers/flagd/src/e2e/tests/in-process.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ describe('in-process', () => {
loadFeatures(GHERKIN_FLAGD, {
// remove filters as we add support for features
// see: https://github.com/open-feature/js-sdk-contrib/issues/1096 and child issues
tagFilter: '@in-process and not @targetURI and not @sync and not @unixsocket and not @deprecated',
tagFilter:
'@in-process and not @targetURI and not @sync and not @unixsocket and not @deprecated and not @fractional-v1 and not @operator-errors',
scenarioNameTemplate: (vars) => {
return `${vars.scenarioTitle} (${vars.scenarioTags.join(',')} ${vars.featureTags.join(',')})`;
},
Expand Down
2 changes: 1 addition & 1 deletion libs/providers/flagd/src/e2e/tests/rpc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe('rpc', () => {
tagFilter:
// remove filters as we add support for features
// see: https://github.com/open-feature/js-sdk-contrib/issues/1096 and child issues
'@rpc and not @targetURI and not @caching and not @unixsocket and not @deprecated',
'@rpc and not @targetURI and not @caching and not @unixsocket and not @deprecated and not @fractional-v1 and not @operator-errors',
scenarioNameTemplate: (vars) => {
return `${vars.scenarioTitle} (${vars.scenarioTags.join(',')} ${vars.featureTags.join(',')})`;
},
Expand Down
60 changes: 35 additions & 25 deletions libs/shared/flagd-core/src/lib/targeting/fractional.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@ import type { EvaluationContextWithLogger } from './common';

export const fractionalRule = 'fractional';

export function fractional(data: unknown, context: EvaluationContextWithLogger): string | null {
type VariantValue = string | number | boolean | null;

export function fractional(data: unknown, context: EvaluationContextWithLogger): VariantValue {
const logger = getLoggerFromContext(context);
if (!Array.isArray(data)) {
return null;
}

const args = Array.from(data);
if (args.length < 2) {
logger.debug(`Invalid ${fractionalRule} configuration: Expected at least 2 buckets, got ${args.length}`);
if (args.length < 1) {
logger.debug(`Invalid ${fractionalRule} configuration: Expected at least 1 bucket, got ${args.length}`);
return null;
}

Expand Down Expand Up @@ -48,37 +50,36 @@ export function fractional(data: unknown, context: EvaluationContextWithLogger):
return null;
}

// hash in signed 32 format. Bitwise operation here works in signed 32 hence the conversion
const hash = new MurmurHash3(bucketBy).result() | 0;
const bucket = (Math.abs(hash) / 2147483648) * 100;
const MAX_WEIGHT = 2147483647;
if (bucketingList.totalWeight > MAX_WEIGHT) {
logger.debug(
`Invalid ${fractionalRule} configuration: sum of weights exceeds Math.MaxInt32 (${MAX_WEIGHT}), got ${bucketingList.totalWeight}`,
);
return null;
}

const hashUint32 = BigInt(new MurmurHash3(bucketBy).result() >>> 0);
const bucket = (hashUint32 * BigInt(bucketingList.totalWeight)) >> BigInt(32);

let sum = 0;
let sum = BigInt(0);
for (let i = 0; i < bucketingList.fractions.length; i++) {
const bucketEntry = bucketingList.fractions[i];

sum += relativeWeight(bucketingList.totalWeight, bucketEntry.fraction);
sum += BigInt(bucketEntry.fraction);

if (sum >= bucket) {
if (sum > bucket) {
return bucketEntry.variant;
}
}

return null;
}

function relativeWeight(totalWeight: number, weight: number): number {
if (weight == 0) {
return 0;
}
return (weight * 100) / totalWeight;
}

function toBucketingList(from: unknown[]): {
fractions: { variant: string; fraction: number }[];
fractions: { variant: VariantValue; fraction: number }[];
totalWeight: number;
} {
// extract bucketing options
const bucketingArray: { variant: string; fraction: number }[] = [];
const bucketingArray: { variant: VariantValue; fraction: number }[] = [];

let totalWeight = 0;
for (let i = 0; i < from.length; i++) {
Expand All @@ -91,19 +92,28 @@ function toBucketingList(from: unknown[]): {
throw new Error('Invalid bucketing entry. Requires at least a variant');
}

if (typeof entry[0] !== 'string') {
throw new Error('Bucketing require variant to be present in string format');
let variant: VariantValue;
if (typeof entry[0] === 'string' || typeof entry[0] === 'number' || typeof entry[0] === 'boolean') {
variant = entry[0];
} else if (entry[0] === null || entry[0] === undefined) {
variant = null;
} else {
throw new Error(
'Bucketing requires variant to be a string, number, or boolean (or a JSONLogic expression that evaluates to one)',
);
}

let weight = 1;
if (entry.length >= 2) {
if (typeof entry[1] !== 'number') {
throw new Error('Bucketing require bucketing percentage to be present');
const raw = entry[1];
if (typeof raw !== 'number' || !Number.isInteger(raw)) {
throw new Error('Bucketing requires weight to be an integer');
}
weight = entry[1];
// negative weights can be the result of rollout calculations, so we clamp to 0 rather than throwing an error
weight = Math.max(0, raw);
}

bucketingArray.push({ fraction: weight, variant: entry[0] });
bucketingArray.push({ fraction: weight, variant });
totalWeight += weight;
}

Expand Down
Loading
Loading