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
26 changes: 19 additions & 7 deletions tensorboard/webapp/feature_flag/store/feature_flag_metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,21 @@ limitations under the License.
==============================================================================*/
import {FeatureFlags} from '../types';

export type BaseFeatureFlagType = boolean | number | string | null | undefined;

export type FeatureFlagType = BaseFeatureFlagType | Array<BaseFeatureFlagType>;
export type FeatureFlagType =
| boolean
| number
| string
| string[]
| null
| undefined;

export type FeatureFlagMetadata<T> = {
defaultValue: T;
// The name of the query param users can use to override the feature flag
// value. If unspecified then users cannot override the feature flag value.
queryParamOverride?: string;
parseValue: (str: string) => T extends (infer U)[] ? U : T; // The type, or, if the type is an array, the type of the array contents
isArray?: boolean;
// Function that translates a query param value into the feature flag value.
parseValue: (str: string) => T;
};

export type FeatureFlagMetadataMapType<T extends FeatureFlags> = {
Expand All @@ -40,6 +46,13 @@ export function parseBooleanOrNull(str: string): boolean | null {
return parseBoolean(str);
}

export function parseStringArray(str: string): string[] {
if (!str) {
return [];
}
return str.split(',');
}

export const FeatureFlagMetadataMap: FeatureFlagMetadataMapType<FeatureFlags> =
{
scalarsBatchSize: {
Expand All @@ -60,8 +73,7 @@ export const FeatureFlagMetadataMap: FeatureFlagMetadataMapType<FeatureFlags> =
enabledExperimentalPlugins: {
defaultValue: [],
queryParamOverride: 'experimentalPlugin',
parseValue: (str: string) => str,
isArray: true,
parseValue: parseStringArray,
},
enabledLinkedTime: {
defaultValue: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import {parseBoolean, parseBooleanOrNull} from './feature_flag_metadata';

/* Copyright 2022 The TensorFlow Authors. All Rights Reserved.

Licensed under the Apache License, Version 2.0 (the "License");
Expand All @@ -14,6 +12,12 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
==============================================================================*/
import {
parseBoolean,
parseBooleanOrNull,
parseStringArray,
} from './feature_flag_metadata';

describe('feature flag query parameters', () => {
describe('parseBoolean', () => {
it('"false" should evaluate to false', () => {
Expand Down Expand Up @@ -42,4 +46,23 @@ describe('feature flag query parameters', () => {
expect(parseBooleanOrNull('')).toBeTrue();
});
});

describe('parseStringArray', () => {
it('parses empty value to empty array', () => {
expect(parseStringArray('')).toEqual([]);
});

it('parses single value to single element array', () => {
expect(parseStringArray('value1')).toEqual(['value1']);
});

it('parses multiple values to array', () => {
expect(parseStringArray('value1,value2,,value3')).toEqual([
'value1',
'value2',
'',
'value3',
]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -359,9 +359,7 @@ describe('core deeplink provider', () => {
store.refreshState();

expect(queryParamsSerialized[queryParamsSerialized.length - 1]).toEqual([
{key: 'experimentalPlugin', value: 'foo'},
{key: 'experimentalPlugin', value: 'bar'},
{key: 'experimentalPlugin', value: 'baz'},
{key: 'experimentalPlugin', value: 'foo,bar,baz'},
]);
});

Expand Down
62 changes: 25 additions & 37 deletions tensorboard/webapp/routes/feature_flag_serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,33 +26,31 @@ export function featureFlagsToSerializableQueryParams<T extends FeatureFlags>(
): SerializableQueryParams {
return Object.entries(overriddenFeatureFlags)
.map(([featureFlag, featureValue]) => {
const key =
featureFlagMetadataMap[featureFlag as keyof FeatureFlags]
?.queryParamOverride;
const featureFlagMetadata: FeatureFlagMetadata<any> =
featureFlagMetadataMap[featureFlag as keyof FeatureFlags];
if (!featureFlagMetadata) {
// No metadata for this feature flag. Shouldn't happen but we must
// include the check for the compiler.
// Return empty item. Will be filtered out.
return {};
}
const key = featureFlagMetadata.queryParamOverride;
if (!key || featureValue === undefined) {
return [];
// Feature flag has no query param or there was no overriden value
// specified.
// Return empty item. Will be filtered out.
return {};
}
/**
* Features with array values should be serialized as multiple query params, e.g.
* enabledExperimentalPlugins: {
* queryParamOverride: 'experimentalPlugin',
* values: ['foo', 'bar'],
* }
* Should be serialized to:
* ?experimentalPlugin=foo&experimentalPlugin=bar
*
* Because values can be arrays it is easiest to convert non array values to an
* array, then flatten the result.
*/
const values = Array.isArray(featureValue)
? featureValue
: [featureValue];
return values.map((value) => ({
return {
key,
value: value?.toString(),
}));
// Note that all FeatureFlagType (string | number | boolean | string[])
// support toString() and toString() happens to output the format we
// want. Mostly notably, string[].toString() effectively does join(',').
// If this does hold when we add new types then consider adding support
// for custom encoders.
value: featureValue?.toString(),
};
})
.flat()
.filter(
({key, value}) => key && value !== undefined
) as SerializableQueryParams;
Expand All @@ -64,26 +62,16 @@ export function featureFlagsToSerializableQueryParams<T extends FeatureFlags>(
export function getFeatureFlagValueFromSearchParams<T extends FeatureFlagType>(
flagMetadata: FeatureFlagMetadata<T>,
params: URLSearchParams
): T | T[] | null {
): T | null {
const queryParamOverride = flagMetadata.queryParamOverride;
if (!queryParamOverride || !params.has(queryParamOverride)) {
return null;
}
/**
* Array type feature flags are intended to be overridden multiple times
* i.e. ?experimentalPlugin=foo&experimentalPlugin=bar
* By using get params.getAll we can reuse the logic between array and non array types.
*/
const paramValues: T[] = params.getAll(queryParamOverride).map((value) => {
return flagMetadata.parseValue(value) as T;
});
if (!paramValues.length) {
const paramValue = params.get(queryParamOverride);
if (paramValue == null) {
return null;
}

// There will always be an array of values but if the flag is not declared to be an array
// there SHOULD only be a single value which should then be returned.
return flagMetadata.isArray ? paramValues : paramValues[0];
return flagMetadata.parseValue(paramValue);
}

/**
Expand Down
42 changes: 25 additions & 17 deletions tensorboard/webapp/routes/feature_flag_serializer_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ limitations under the License.
import {
FeatureFlagMetadata,
FeatureFlagMetadataMapType,
parseBoolean,
parseStringArray,
} from '../feature_flag/store/feature_flag_metadata';
import {
featureFlagsToSerializableQueryParams,
Expand All @@ -24,6 +26,7 @@ import {

const FEATURE_A_NAME = 'featureA';
const FEATURE_B_NAME = 'featureB';
const FEATURE_C_NAME = 'featureC';

describe('feature flag serializer', () => {
let featureFlagsMetadata: FeatureFlagMetadataMapType<any> = {};
Expand All @@ -35,10 +38,14 @@ describe('feature flag serializer', () => {
parseValue: (s: string) => s,
},
[FEATURE_B_NAME]: {
defaultValue: 'feature_b_456',
defaultValue: ['feature_b_456'],
queryParamOverride: 'feature_b',
isArray: true,
parseValue: (s: string) => s,
parseValue: parseStringArray,
},
[FEATURE_C_NAME]: {
defaultValue: true,
queryParamOverride: 'feature_c',
parseValue: parseBoolean,
},
};
});
Expand All @@ -54,12 +61,12 @@ describe('feature flag serializer', () => {

it('should not serialize feature flags with missing metadata', () => {
let serializableQueryParams = featureFlagsToSerializableQueryParams(
{featureC: 'c'} as any,
{featureD: 'd'} as any,
featureFlagsMetadata
);
expect(serializableQueryParams).toEqual([]);
serializableQueryParams = featureFlagsToSerializableQueryParams(
{featureC: 'c', featureA: 'a'} as any,
{featureD: 'd', featureA: 'a'} as any,
featureFlagsMetadata
);
expect(serializableQueryParams).toEqual([
Expand All @@ -72,22 +79,26 @@ describe('feature flag serializer', () => {

it('should serialize feature flags with falsy values', () => {
const serializableQueryParams = featureFlagsToSerializableQueryParams(
{featureB: false, featureA: ''} as any,
{featureB: [''], featureA: '', featureC: false} as any,
featureFlagsMetadata
);
expect(serializableQueryParams).toEqual([
{
key: 'feature_b',
value: 'false',
value: '',
},
{
key: 'feature_a',
value: '',
},
{
key: 'feature_c',
value: 'false',
},
]);
});

it('should return multiple entries for features with array values', () => {
it('should return single entry for features with string[] type', () => {
const serializableQueryParams = featureFlagsToSerializableQueryParams(
{featureA: 'a', featureB: ['foo', 'bar']} as any,
featureFlagsMetadata
Expand All @@ -99,11 +110,7 @@ describe('feature flag serializer', () => {
},
{
key: 'feature_b',
value: 'foo',
},
{
key: 'feature_b',
value: 'bar',
value: 'foo,bar',
},
]);
});
Expand All @@ -129,18 +136,18 @@ describe('feature flag serializer', () => {
expect(value).toBeNull();
});

it('returns first value when feature flag is not an array', () => {
it('returns first value when multiple matching query params', () => {
const value = getFeatureFlagValueFromSearchParams(
featureFlagsMetadata[FEATURE_A_NAME],
new URLSearchParams('?feature_a=foo&feature_a=bar')
);
expect(value).toEqual('foo');
});

it('returns array of values when feature flag is an array', () => {
it('returns array of values when feature flag has array decoder', () => {
const value = getFeatureFlagValueFromSearchParams(
featureFlagsMetadata[FEATURE_B_NAME],
new URLSearchParams('?feature_b=foo&feature_b=bar')
new URLSearchParams('?feature_b=foo,bar')
);
expect(value).toEqual(['foo', 'bar']);
});
Expand All @@ -166,11 +173,12 @@ describe('feature flag serializer', () => {
it('parses flag values correctly', () => {
const featureFlags = getOverriddenFeatureFlagValuesFromSearchParams(
featureFlagsMetadata,
new URLSearchParams('?feature_a=foo&feature_b=bar')
new URLSearchParams('?feature_a=foo&feature_b=bar&feature_c=false')
);
expect(featureFlags).toEqual({
featureA: 'foo',
featureB: ['bar'],
featureC: false,
} as any);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ describe('tb_feature_flag_data_source', () => {

it('returns enabledExperimentalPlugins from the query params', () => {
getParamsSpy.and.returnValue(
new URLSearchParams('experimentalPlugin=a&experimentalPlugin=b')
new URLSearchParams('experimentalPlugin=a,b')
);
expect(dataSource.getFeatures()).toEqual({
enabledExperimentalPlugins: ['a', 'b'],
Expand Down