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

support feature flags #65

Merged
merged 24 commits into from
Jul 30, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c035f89
support feature flags
Eskibear Apr 16, 2024
6730571
Apply suggestions from code review
Eskibear Jun 5, 2024
e269596
fix default refresh interval in comments
Eskibear Jun 5, 2024
2d0c820
Merge branch 'main' of github.com:Azure/AppConfiguration-JavaScriptPr…
Eskibear Jun 11, 2024
708f759
update constants to SNAKE_CASE
Eskibear Jun 11, 2024
7f62f14
Merge branch 'main' into yanzh/fm
Eskibear Jun 11, 2024
3cd5f59
use extracted list api
Eskibear Jun 11, 2024
923c124
throw error if selectors not provided
Eskibear Jun 12, 2024
8e2de54
parse feature flag directly
Eskibear Jun 14, 2024
1182638
refactor: rename validateSelectors to getValidSelectors
Eskibear Jun 19, 2024
99f2c29
refresh APIs apply for both kv and ff
Eskibear Jun 19, 2024
dc4f553
dedup loaded feature flags among selectors
Eskibear Jun 20, 2024
c29d834
Merge branch 'main' of github.com:Azure/AppConfiguration-JavaScriptPr…
Eskibear Jun 20, 2024
6411a30
extract requestTraceOptions as private member
Eskibear Jun 24, 2024
4ec140a
list feature flags with pageEtags
Eskibear Jul 2, 2024
453dad3
update mocked client to support byPage iterator
Eskibear Jul 3, 2024
e4eedc9
handle page deletion
Eskibear Jul 3, 2024
87316f3
upgrade @azure/app-configuration to 1.6.1
Eskibear Jul 19, 2024
1499cbf
Merge branch 'main' of github.com:Azure/AppConfiguration-JavaScriptPr…
Eskibear Jul 19, 2024
0c8a4f2
add tests for pageEtag based refresh
Eskibear Jul 19, 2024
a3f068e
Merge branch 'main' of github.com:Azure/AppConfiguration-JavaScriptPr…
Eskibear Jul 29, 2024
fb56a0f
remove pageCount as service ensure etag changes on page add/delete
Eskibear Jul 29, 2024
b1cf809
reset timer on 304, maintain refresh interval gap between attempts
Eskibear Jul 29, 2024
3c12654
use empty string as placeholder when page etag is undefined, to keep …
Eskibear Jul 29, 2024
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
136 changes: 121 additions & 15 deletions src/AzureAppConfigurationImpl.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, GetConfigurationSettingOptions, GetConfigurationSettingResponse, ListConfigurationSettingsOptions, isFeatureFlag } from "@azure/app-configuration";
import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, GetConfigurationSettingOptions, GetConfigurationSettingResponse, ListConfigurationSettingsOptions, featureFlagPrefix, isFeatureFlag } from "@azure/app-configuration";
import { RestError } from "@azure/core-rest-pipeline";
import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from "./AzureAppConfiguration";
import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions";
import { IKeyValueAdapter } from "./IKeyValueAdapter";
import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter";
import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./RefreshOptions";
import { Disposable } from "./common/disposable";
import { FEATURE_FLAGS_KEY_NAME, FEATURE_MANAGEMENT_KEY_NAME } from "./featureManagement/constants";
import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter";
import { RefreshTimer } from "./refresh/RefreshTimer";
import { getConfigurationSettingWithTrace, listConfigurationSettingsWithTrace, requestTracingEnabled } from "./requestTracing/utils";
Expand Down Expand Up @@ -40,6 +41,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
#sentinels: ConfigurationSettingId[] = [];
#refreshTimer: RefreshTimer;

// Feature flags
#featureFlagRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS;
#featureFlagRefreshTimer: RefreshTimer;

constructor(
client: AppConfigurationClient,
options: AzureAppConfigurationOptions | undefined
Expand Down Expand Up @@ -84,13 +89,27 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
this.#refreshTimer = new RefreshTimer(this.#refreshInterval);
}

// TODO: should add more adapters to process different type of values
// feature flag, others
// feature flag options
if (options?.featureFlagOptions?.enabled && options.featureFlagOptions.refresh?.enabled) {
const { refreshIntervalInMs } = options.featureFlagOptions.refresh;

// custom refresh interval
if (refreshIntervalInMs !== undefined) {
if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) {
throw new Error(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`);
} else {
this.#featureFlagRefreshInterval = refreshIntervalInMs;
}
}

this.#featureFlagRefreshTimer = new RefreshTimer(this.#featureFlagRefreshInterval);
}

this.#adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions));
this.#adapters.push(new JsonKeyValueAdapter());
}

// ReadonlyMap APIs
// #region ReadonlyMap APIs
get<T>(key: string): T | undefined {
return this.#configMap.get(key);
}
Expand Down Expand Up @@ -122,16 +141,25 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
[Symbol.iterator](): IterableIterator<[string, any]> {
return this.#configMap[Symbol.iterator]();
}
// #endregion

get #refreshEnabled(): boolean {
return !!this.#options?.refreshOptions?.enabled;
}

get #featureFlagEnabled(): boolean {
return !!this.#options?.featureFlagOptions?.enabled;
}

get #featureFlagRefreshEnabled(): boolean {
return this.#featureFlagEnabled && !!this.#options?.featureFlagOptions?.refresh?.enabled;
}

async #loadSelectedKeyValues(): Promise<ConfigurationSetting[]> {
const loadedSettings: ConfigurationSetting[] = [];

// validate selectors
const selectors = getValidSelectors(this.#options?.selectors);
const selectors = getValidKeyValueSelectors(this.#options?.selectors);

for (const selector of selectors) {
const listOptions: ListConfigurationSettingsOptions = {
Expand Down Expand Up @@ -196,17 +224,58 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
keyValues.push([key, value]);
}

this.#configMap.clear(); // clear existing key-values in case of configuration setting deletion
this.#clearLoadedKeyValues(); // clear existing key-values in case of configuration setting deletion
for (const [k, v] of keyValues) {
this.#configMap.set(k, v);
}
}

async #clearLoadedKeyValues() {
for(const key of this.#configMap.keys()) {
if (key !== FEATURE_MANAGEMENT_KEY_NAME) {
this.#configMap.delete(key);
}
}
}

async #loadFeatureFlags() {
const featureFlags: unknown[] = [];
const featureFlagSelectors = getValidFeatureFlagSelectors(this.#options?.featureFlagOptions?.selectors);
for (const selector of featureFlagSelectors) {
const listOptions: ListConfigurationSettingsOptions = {
keyFilter: `${featureFlagPrefix}${selector.keyFilter}`,
labelFilter: selector.labelFilter
};
const requestTraceOptions = {
requestTracingEnabled: this.#requestTracingEnabled,
initialLoadCompleted: this.#isInitialLoadCompleted,
appConfigOptions: this.#options
};
const settings = listConfigurationSettingsWithTrace(
requestTraceOptions,
this.#client,
listOptions
);
for await (const setting of settings) {
if (isFeatureFlag(setting)) {
const flag = JSON.parse(setting.value);
featureFlags.push(flag)
}
}
}

// feature_management is a reserved key, and feature_flags is an array of feature flags
this.#configMap.set(FEATURE_MANAGEMENT_KEY_NAME, { [FEATURE_FLAGS_KEY_NAME]: featureFlags });
avanigupta marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Load the configuration store for the first time.
*/
async load() {
await this.#loadSelectedAndWatchedKeyValues();
if (this.#featureFlagEnabled) {
await this.#loadFeatureFlags();
}
// Mark all settings have loaded at startup.
this.#isInitialLoadCompleted = true;
}
Expand Down Expand Up @@ -258,10 +327,19 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
* Refresh the configuration store.
*/
async refresh(): Promise<void> {
if (!this.#refreshEnabled) {
throw new Error("Refresh is not enabled.");
if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) {
throw new Error("Refresh is not enabled for key-values and feature flags.");
}

if (this.#refreshEnabled) {
await this.#refreshKeyValues();
}
if (this.#featureFlagRefreshEnabled) {
await this.#refreshFeatureFlags();
}
}

async #refreshKeyValues(): Promise<void> {
// if still within refresh interval/backoff, return
if (!this.#refreshTimer.canRefresh()) {
return Promise.resolve();
Expand Down Expand Up @@ -299,9 +377,25 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
}
}

async #refreshFeatureFlags(): Promise<void> {
// if still within refresh interval/backoff, return
if (!this.#featureFlagRefreshTimer.canRefresh()) {
return Promise.resolve();
}

try {
await this.#loadFeatureFlags();
Eskibear marked this conversation as resolved.
Show resolved Hide resolved
this.#featureFlagRefreshTimer.reset();
} catch (error) {
// if refresh failed, backoff
this.#featureFlagRefreshTimer.backoff();
throw error;
}
}
avanigupta marked this conversation as resolved.
Show resolved Hide resolved

onRefresh(listener: () => any, thisArg?: any): Disposable {
if (!this.#refreshEnabled) {
throw new Error("Refresh is not enabled.");
throw new Error("Refresh is not enabled for key-values.");
}

const boundedListener = listener.bind(thisArg);
Expand Down Expand Up @@ -371,12 +465,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
}
}

function getValidSelectors(selectors?: SettingSelector[]) {
if (!selectors || selectors.length === 0) {
// Default selector: key: *, label: \0
return [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }];
}

function validateSelectors(selectors: SettingSelector[]) {
Eskibear marked this conversation as resolved.
Show resolved Hide resolved
// below code deduplicates selectors by keyFilter and labelFilter, the latter selector wins
const uniqueSelectors: SettingSelector[] = [];
for (const selector of selectors) {
Expand All @@ -401,3 +490,20 @@ function getValidSelectors(selectors?: SettingSelector[]) {
return selector;
});
}

function getValidKeyValueSelectors(selectors?: SettingSelector[]) {
if (!selectors || selectors.length === 0) {
// Default selector: key: *, label: \0
return [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }];
}
return validateSelectors(selectors);
}

function getValidFeatureFlagSelectors(selectors?: SettingSelector[]) {
if (!selectors || selectors.length === 0) {
// selectors must be explicitly provided.
throw new Error("Feature flag selectors must be provided.");
} else {
return validateSelectors(selectors);
}
}
7 changes: 7 additions & 0 deletions src/AzureAppConfigurationOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AppConfigurationClientOptions } from "@azure/app-configuration";
import { KeyVaultOptions } from "./keyvault/KeyVaultOptions";
import { RefreshOptions } from "./RefreshOptions";
import { SettingSelector } from "./types";
import { FeatureFlagOptions } from "./featureManagement/FeatureFlagOptions";

export const MaxRetries = 2;
export const MaxRetryDelayInMs = 60000;
Expand Down Expand Up @@ -36,8 +37,14 @@ export interface AzureAppConfigurationOptions {
* Specifies options used to resolve Vey Vault references.
*/
keyVaultOptions?: KeyVaultOptions;

/**
* Specifies options for dynamic refresh key-values.
*/
refreshOptions?: RefreshOptions;

/**
* Specifies options used to configure feature flags.
*/
featureFlagOptions?: FeatureFlagOptions;
}
14 changes: 14 additions & 0 deletions src/RefreshOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,17 @@ export interface RefreshOptions {
*/
watchedSettings?: WatchedSetting[];
}

export interface FeatureFlagRefreshOptions {
/**
* Specifies whether the provider should automatically refresh all feature flags if any feature flag changes.
*/
enabled: boolean;

/**
* Specifies the minimum time that must elapse before checking the server for any new changes.
* Default value is 30 seconds. Must be greater than 1 second.
* Any refresh operation triggered will not update the value for a key until after the interval.
*/
refreshIntervalInMs?: number;
}
30 changes: 30 additions & 0 deletions src/featureManagement/FeatureFlagOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { FeatureFlagRefreshOptions } from "../RefreshOptions";
import { SettingSelector } from "../types";

/**
* Options used to configure feature flags.
*/
export interface FeatureFlagOptions {
/**
* Specifies whether feature flags will be loaded from Azure App Configuration.

*/
enabled: boolean;

/**
* Specifies the selectors used to filter feature flags.
*
* @remarks
* keyFilter of selector will be prefixed with "appconfig.featureflag/" when request is sent.
* If no selectors are specified then no feature flags will be retrieved.
avanigupta marked this conversation as resolved.
Show resolved Hide resolved
*/
selectors?: SettingSelector[];

/**
* Specifies how feature flag refresh is configured. All selected feature flags will be watched for changes.
*/
refresh?: FeatureFlagRefreshOptions;
}
5 changes: 5 additions & 0 deletions src/featureManagement/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

export const FEATURE_MANAGEMENT_KEY_NAME = "feature_management";
export const FEATURE_FLAGS_KEY_NAME = "feature_flags";
Loading