Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Local Evaluation without awaiting a Promise #140

Closed
wants to merge 1 commit into from
Closed
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
67 changes: 67 additions & 0 deletions sdk/index.ts
Original file line number Diff line number Diff line change
@@ -55,6 +55,8 @@ export class Flagsmith {
private onEnvironmentChange?: (error: Error | null, result: EnvironmentModel) => void;
private analyticsProcessor?: AnalyticsProcessor;
private logger: Logger;
/** True if the environment has loaded at least once */
private ready: boolean;
/**
* A Flagsmith client.
*
@@ -114,6 +116,7 @@ export class Flagsmith {
this.logger = data.logger || pino();
this.offlineMode = data.offlineMode || false;
this.offlineHandler = data.offlineHandler;
this.ready = this.offlineMode

// argument validation
if (this.offlineMode && !this.offlineHandler) {
@@ -235,6 +238,52 @@ export class Flagsmith {
return this.getIdentityFlagsFromApi(identifier, traits);
}

/**
* Get all the flags for the current environment for a given identity.
* Local evaluation mode only. If using a custom cache, then it should
* return synchronously as well.
*
* @param {string} identifier a unique identifier for the identity in the
* current environment, e.g. email address, username, uuid
* @param {{[key:string]:any}} traits? a dictionary of traits to add /
* update on the identity in Flagsmith, e.g. {"num_orders": 10}
* @returns Flags object holding all the flags for the given identity.
*/
getIdentityFlagsSync(identifier: string, traits?: { [key: string]: any }): Flags {
if (!identifier) {
throw new Error('`identifier` argument is missing or invalid.');
}
if (!this.enableLocalEvaluation && !this.offlineMode) {
throw new Error('local evaluation and/or offline mode is not enabled');
}
if (!this.ready) {
throw new Error('environment not loaded yet; try again later')
}
const cachedItem = !!this.cache && (this.cache.get(`flags-${identifier}`));
if (!!cachedItem) {
if (cachedItem instanceof Flags) {
return cachedItem;
}
throw new Error('cache returned a Promise in sync mode');
}
const identityModel = this.buildIdentityModel(
identifier,
Object.entries(traits || {}).map(([key, value]) => ({ key, value }))
);
const featureStates = getIdentityFeatureStates(this.environment, identityModel);
const flags = Flags.fromFeatureStateModels({
featureStates: featureStates,
analyticsProcessor: this.analyticsProcessor,
defaultFlagHandler: this.defaultFlagHandler,
identityID: identityModel.djangoID || identityModel.compositeKey
});
if (!!this.cache) {
// @ts-ignore node-cache types are incorrect, ttl should be optional
this.cache.set(`flags-${identifier}`, flags);
}
return flags;
}

/**
* Get the segments for the current environment for a given identity. Will also
upsert all traits to the Flagsmith API for future evaluations. Providing a
@@ -296,6 +345,7 @@ export class Flagsmith {
this.environment.identityOverrides.map(identity => [identity.identifier, identity]
));
}
this.ready = true;
if (this.onEnvironmentChange) {
this.onEnvironmentChange(null, this.environment);
}
@@ -306,6 +356,23 @@ export class Flagsmith {
}
}

/**
* Wait for the environment document to load. If the request fails then
* this promise is rejected. Useful for local evaluation mode.
*/
async readyCheck(): Promise<void> {
if (this.ready) { return }
if (!this.environmentDataPollingManager || this.environmentDataPollingManager.isStopped) {
throw new Error('polling manager is stopped')
}
if (!this.environmentPromise) {
// This should never throw, but await anyway to avoid a dangling
// promise
await this.updateEnvironment()
}
await this.environmentPromise
}

async close() {
this.environmentDataPollingManager?.stop();
}
2 changes: 2 additions & 0 deletions sdk/polling_manager.ts
Original file line number Diff line number Diff line change
@@ -27,4 +27,6 @@ export class EnvironmentDataPollingManager {
}
clearInterval(this.interval);
}

get isStopped() { return !!this.interval }
}
2 changes: 1 addition & 1 deletion sdk/types.ts
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@ import { Logger } from "pino";
import { BaseOfflineHandler } from "./offline_handlers";

export interface FlagsmithCache {
get(key: string): Promise<Flags|undefined> | undefined;
get(key: string): Promise<Flags|undefined> | Flags | undefined;
set(key: string, value: Flags, ttl: string | number): boolean | Promise<boolean>;
has(key: string): boolean | Promise<boolean>;
[key: string]: any;
60 changes: 59 additions & 1 deletion tests/sdk/flagsmith-cache.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import fetch, { Headers } from 'node-fetch';
import { environmentJSON, environmentModel, flagsJSON, flagsmith, identitiesJSON, TestCache } from './utils';
import {
TestCache,
TestCacheSync,
environmentJSON,
environmentModel,
flagsJSON,
flagsmith,
identitiesJSON,
} from './utils';

jest.mock('node-fetch');
jest.mock('../../sdk/polling_manager');
@@ -148,3 +156,53 @@ test('test_cache_used_for_identity_flags_local_evaluation', async () => {
});

test('test_cache_used_for_all_flags', async () => { });

test('test_cache_used_for_identity_flags_sync', async () => {
// @ts-expect-error jest mocks not added to typedef
fetch.mockReturnValue(Promise.resolve(new Response(environmentJSON())));

const cache = new TestCacheSync();
const set = jest.spyOn(cache, 'set');

const identifier = 'identifier';
const traits = { some_trait: 'some_value' };
const flg = flagsmith({
cache,
environmentKey: 'ser.key',
enableLocalEvaluation: true,
});

await flg.readyCheck();

flg.getIdentityFlagsSync(identifier, traits).allFlags();
const identityFlags = flg.getIdentityFlagsSync(identifier, traits).allFlags();

expect(set).toBeCalled();
expect(cache.has('flags-identifier')).toBe(true);

expect(fetch).toBeCalledTimes(1);

expect(identityFlags[0].enabled).toBe(true);
expect(identityFlags[0].value).toBe('some-value');
expect(identityFlags[0].featureName).toBe('some_feature');
});

test('test_cache_used_for_identity_flags_sync_error', async () => {
// @ts-expect-error jest mocks not added to typedef
fetch.mockReturnValue(Promise.resolve(new Response(environmentJSON())));

const cache = new TestCache();
const identifier = 'identifier';

const flg = flagsmith({
cache,
environmentKey: 'ser.key',
enableLocalEvaluation: true,
});

await flg.readyCheck();

expect(() => {
flg.getIdentityFlagsSync(identifier);
}).toThrow('returned a Promise');
});
43 changes: 43 additions & 0 deletions tests/sdk/flagsmith-identity-flags.test.ts
Original file line number Diff line number Diff line change
@@ -155,3 +155,46 @@ test('test_get_identity_flags_multivariate_value_with_local_evaluation_enabled',
expect(identityFlags.getFeatureValue('mv_feature')).toBe('bar');
expect(identityFlags.isFeatureEnabled('mv_feature')).toBe(false);
});

test('test_get_identity_flags_sync_basic', async () => {
// @ts-ignore
fetch.mockReturnValue(Promise.resolve(new Response(environmentJSON())));
const identifier = 'identifier';

const flg = flagsmith({
environmentKey: 'ser.key',
enableLocalEvaluation: true,
});

await flg.readyCheck();

const identityFlags = flg.getIdentityFlagsSync(identifier);

expect(identityFlags.getFeatureValue('mv_feature')).toBe('bar');
expect(identityFlags.isFeatureEnabled('mv_feature')).toBe(false);
});

test('test_get_identity_flags_sync_errors', async () => {
// @ts-ignore
fetch.mockReturnValue(Promise.resolve(new Response(environmentJSON())));

const flg = flagsmith({
environmentKey: 'ser.key',
enableLocalEvaluation: true,
});
expect(() => {
flg.getIdentityFlagsSync('example');
}).toThrow('not loaded yet');
await flg.readyCheck();
expect(() => {
flg.getIdentityFlagsSync('');
}).toThrow('missing or invalid');

const f2 = flagsmith({
environmentKey: 'ser.key',
enableLocalEvaluation: false,
});
expect(() => {
f2.getIdentityFlagsSync('example');
}).toThrow('not enabled');
});
17 changes: 17 additions & 0 deletions tests/sdk/utils.ts
Original file line number Diff line number Diff line change
@@ -24,6 +24,23 @@ export class TestCache implements FlagsmithCache {
}
}

export class TestCacheSync implements FlagsmithCache {
cache: Record<string, Flags> = {};

get(name: string): Flags|undefined {
return this.cache[name];
}

has(name: string): boolean {
return !!this.cache[name];
}

set(name: string, value: Flags, ttl: number|string): boolean {
this.cache[name] = value;
return true
}
}

export function analyticsProcessor() {
return new AnalyticsProcessor({
environmentKey: 'test-key',