Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
c8ab1c9
Support dynamic refresh
Eskibear Oct 23, 2023
b282211
etag-based refresh
Eskibear Oct 31, 2023
32ebd48
Merge remote-tracking branch 'origin/main' into dynamic-refresh
Eskibear Nov 9, 2023
8ba1252
rewrite refresh.test with TS
Eskibear Nov 9, 2023
261e72f
add refreshOptions.enabled
Eskibear Nov 9, 2023
7ccf26f
prepend _ to private members for clarity
Eskibear Nov 9, 2023
d3a6279
Merge remote-tracking branch 'origin/main' into dynamic-refresh
Eskibear Nov 9, 2023
6522085
Update mocked func for app-configuration v1.5.0
Eskibear Nov 9, 2023
47e5c34
remove workaround for null label
Eskibear Nov 9, 2023
7b8c9b9
also trace requests for refresh
Eskibear Nov 16, 2023
1b64fb7
add more test cases for unexpected refreshOptions
Eskibear Nov 16, 2023
061fadf
Merge branch 'main' into dynamic-refresh
Eskibear Nov 17, 2023
179ce3e
revert renaming private fields with _ prefix
Eskibear Nov 21, 2023
841baa1
Merge branch 'main' into dynamic-refresh
Eskibear Nov 30, 2023
03d53b9
add backoff timer
Eskibear Dec 26, 2023
dc8b087
Merge branch 'main' into dynamic-refresh
Eskibear Dec 27, 2023
431e91e
backoff when error occurs during refresh
Eskibear Dec 27, 2023
af9ea33
update comment docs
Eskibear Jan 2, 2024
677eaa0
fix backoff end time on reset
Eskibear Jan 2, 2024
8e1c1a4
make backoff time calc clearer
Eskibear Jan 2, 2024
1da9647
Block wildcard chars in watched settings
Eskibear Jan 3, 2024
13e06a5
Apply wording suggestions
Eskibear Jan 3, 2024
e72887a
Remove LinkedList and update onRefreshListeners to use an array
Eskibear Jan 3, 2024
7514768
Merge remote-tracking branch 'origin/main' into dynamic-refresh
Eskibear Jan 3, 2024
2b867e9
fix error message in test case
Eskibear Jan 3, 2024
46cf7b9
adopt private properties
Eskibear Jan 3, 2024
5b3ab06
Refactor refresh timer method name
Eskibear Jan 3, 2024
18bbd4e
explain refresh scenario in example comments
Eskibear Jan 3, 2024
9f32ceb
Merge remote-tracking branch 'origin/main' into dynamic-refresh
Eskibear Jan 4, 2024
8e1a2ab
Add timeout to dynamic refresh test
Eskibear Jan 4, 2024
0e347be
Fix refresh timer logic in AzureAppConfigurationImpl.ts
Eskibear Jan 4, 2024
46be338
support refresh on watched setting deletion
Eskibear Jan 4, 2024
c8c8d8e
Remove unused variable
Eskibear Jan 4, 2024
7663a27
export type Disposable
Eskibear Jan 4, 2024
ead82d0
add detailed description for refresh timer
Eskibear Jan 4, 2024
e0c9736
Refactor RefreshTimer class to use efficient power of two calculation
Eskibear Jan 4, 2024
0486c10
rename variable name for clarity
Eskibear Jan 4, 2024
fe3614f
remove redundant code
Eskibear Jan 4, 2024
1099ec7
Merge branch 'main' into dynamic-refresh
Eskibear Jan 5, 2024
98b12e2
limit max exponential to 30 and remove utils no longer needed
Eskibear Jan 10, 2024
506c8a6
throw error on refresh when refresh is not enabled
Eskibear Jan 10, 2024
5d80ccc
load watched settings if not coverred by selectors
Eskibear Jan 10, 2024
e311ce9
add comments for the Map key trick
Eskibear Jan 10, 2024
e3ed82f
deduce type from state isInitialLoadCompleted
Eskibear Jan 10, 2024
d838076
revert unnecessary whitespace change
Eskibear Jan 11, 2024
e03036d
simplify request tracing header utils
Eskibear Jan 11, 2024
97e0350
Exclude watched settings from configuration
Eskibear Jan 11, 2024
d0440dc
Change sentinels to array type to ensure correctness
Eskibear Jan 12, 2024
4bc37e1
remove unnecessary check, as key is non-null
Eskibear Jan 12, 2024
8acf87b
Do not refresh when watched setting remains not loaded
Eskibear Jan 18, 2024
fd60862
simplify nested if blocks
Eskibear Jan 18, 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
3 changes: 2 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,6 @@
"avoidEscape": true
}
],
},
"@typescript-eslint/no-explicit-any": "off"
}
}
44 changes: 44 additions & 0 deletions examples/refresh.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import * as dotenv from "dotenv";
import { promisify } from "util";
dotenv.config();
const sleepInMs = promisify(setTimeout);

/**
* This example retrives all settings with key following pattern "app.settings.*", i.e. starting with "app.settings.".
* With the option `trimKeyPrefixes`, it trims the prefix "app.settings." from keys for simplicity.
* Value of config "app.settings.message" will be printed.
*
* Below environment variables are required for this example:
* - APPCONFIG_CONNECTION_STRING
*/

import { load } from "@azure/app-configuration-provider";
const connectionString = process.env.APPCONFIG_CONNECTION_STRING;
const settings = await load(connectionString, {
selectors: [{
keyFilter: "app.settings.*"
}],
trimKeyPrefixes: ["app.settings."],
refreshOptions: {
watchedSettings: [{ key: "app.settings.sentinel" }],
refreshIntervalInMs: 10 * 1000 // Default value is 30 seconds, shorted for this sample
}
});

console.log("Update the `message` in your App Configuration store using Azure portal or CLI.")
console.log("First, update the `message` value, and then update the `sentinel` key value.")

// eslint-disable-next-line no-constant-condition
while (true) {
// Refreshing the configuration setting
await settings.refresh();

// Current value of message
console.log(settings.get("message"));

// Waiting before the next refresh
await sleepInMs(5000);
}
17 changes: 15 additions & 2 deletions src/AzureAppConfiguration.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { Disposable } from "./common/disposable";

export type AzureAppConfiguration = {
// methods for advanced features, e.g. refresh()
} & ReadonlyMap<string, unknown>;
/**
* API to trigger refresh operation.
*/
refresh(): Promise<void>;

/**
* API to register callback listeners, which will be called only when a refresh operation successfully updates key-values.
*
* @param listener Callback funtion to be registered.
* @param thisArg Optional. Value to use as this when executing callback.
*/
onRefresh(listener: () => any, thisArg?: any): Disposable;
} & ReadonlyMap<string, any>;
115 changes: 98 additions & 17 deletions src/AzureAppConfigurationImpl.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { AppConfigurationClient, ConfigurationSetting, ListConfigurationSettingsOptions } from "@azure/app-configuration";
import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, ListConfigurationSettingsOptions } from "@azure/app-configuration";
import { AzureAppConfiguration } from "./AzureAppConfiguration";
import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions";
import { IKeyValueAdapter } from "./IKeyValueAdapter";
import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter";
import { KeyFilter } from "./KeyFilter";
import { LabelFilter } from "./LabelFilter";
import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter";
import { CorrelationContextHeaderName } from "./requestTracing/constants";
import { CorrelationContextHeaderName, RequestType } from "./requestTracing/constants";
import { createCorrelationContextHeader, requestTracingEnabled } from "./requestTracing/utils";
import { DefaultRefreshIntervalInMs, MinimumRefreshIntervalInMs } from "./RefreshOptions";
import { LinkedList } from "./common/linkedList";
import { Disposable } from "./common/disposable";

export class AzureAppConfigurationImpl extends Map<string, unknown> implements AzureAppConfiguration {
private adapters: IKeyValueAdapter[] = [];
Expand All @@ -20,7 +23,11 @@ export class AzureAppConfigurationImpl extends Map<string, unknown> implements A
*/
private sortedTrimKeyPrefixes: string[] | undefined;
private readonly requestTracingEnabled: boolean;
private correlationContextHeader: string | undefined;
// Refresh
private refreshIntervalInMs: number | undefined;
private onRefreshListeners: LinkedList<() => any> | undefined;
private lastUpdateTimestamp: number;
private sentinels: ConfigurationSettingId[] | undefined;

constructor(
private client: AppConfigurationClient,
Expand All @@ -29,20 +36,41 @@ export class AzureAppConfigurationImpl extends Map<string, unknown> implements A
super();
// Enable request tracing if not opt-out
this.requestTracingEnabled = requestTracingEnabled();
if (this.requestTracingEnabled) {
this.enableRequestTracing();
}

if (options?.trimKeyPrefixes) {
this.sortedTrimKeyPrefixes = [...options.trimKeyPrefixes].sort((a, b) => b.localeCompare(a));
}

if (options?.refreshOptions) {
this.onRefreshListeners = new LinkedList();
this.refreshIntervalInMs = DefaultRefreshIntervalInMs;

const refreshIntervalInMs = this.options?.refreshOptions?.refreshIntervalInMs;
if (refreshIntervalInMs !== undefined) {
if (refreshIntervalInMs < MinimumRefreshIntervalInMs) {
throw new Error(`The refresh interval time cannot be less than ${MinimumRefreshIntervalInMs} milliseconds.`);
} else {
this.refreshIntervalInMs = refreshIntervalInMs;
}
}

this.sentinels = options.refreshOptions.watchedSettings?.map(setting => {
const key = setting.key;
const label = setting.label;
if (key.includes("*") || label?.includes("*")) {
throw new Error("Wildcard key or label filters are not supported for refresh.");
}
return { key, label };
});
}

// TODO: should add more adapters to process different type of values
// feature flag, others
this.adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions));
this.adapters.push(new JsonKeyValueAdapter());
}

public async load() {
public async load(requestType: RequestType = RequestType.Startup) {
const keyValues: [key: string, value: unknown][] = [];

// validate selectors
Expand All @@ -55,23 +83,80 @@ export class AzureAppConfigurationImpl extends Map<string, unknown> implements A
};
if (this.requestTracingEnabled) {
listOptions.requestOptions = {
customHeaders: this.customHeaders()
customHeaders: this.customHeaders(requestType)
}
}

const settings = this.client.listConfigurationSettings(listOptions);

for await (const setting of settings) {
if (setting.key) {
const [key, value] = await this.processAdapters(setting);
const trimmedKey = this.keyWithPrefixesTrimmed(key);
keyValues.push([trimmedKey, value]);
const keyValuePair = await this.processKeyValues(setting);
keyValues.push(keyValuePair);
}
// update etag of sentinels
const matchedSentinel = this.sentinels?.find(s => s.key === setting.key && (s.label ?? null) === setting.label); // Workaround: as undefined label represents the same with null.
if (matchedSentinel) {
matchedSentinel.etag = setting.etag;
}
}
}
for (const [k, v] of keyValues) {
this.set(k, v);
}
this.lastUpdateTimestamp = Date.now();
}

public async refresh(): Promise<void> {
// if no refreshOptions set, return
if (this.sentinels === undefined || this.sentinels.length === 0 || this.refreshIntervalInMs === undefined) {
return Promise.resolve();
}
// if still within refresh interval, return
const now = Date.now();
if (now < this.lastUpdateTimestamp + this.refreshIntervalInMs) {
return Promise.resolve();
}

// try refresh if any of watched settings is changed.
let needRefresh = false;
for (const sentinel of this.sentinels) {
const response = await this.client.getConfigurationSetting(sentinel, {
onlyIfChanged: true
// TODO: do we trace this request by adding custom headers?
});
if (response.statusCode !== 304) { // TODO: can be more robust, e.g. === 200?
// sentinel changed.
sentinel.etag = response.etag;// update etag of the sentinel
needRefresh = true;
break;
}
}
if (needRefresh) {
await this.load(RequestType.Watch);
// run callbacks in async
if (this.onRefreshListeners !== undefined) {
for (const listener of this.onRefreshListeners) {
listener();
}
}
}
}

public onRefresh(listener: () => any, thisArg?: any): Disposable {
if (this.onRefreshListeners === undefined) {
// TODO: Add unit tests
throw new Error("Illegal operation because refreshOptions is not provided on loading.");
}
const boundedListener = listener.bind(thisArg);
const remove = this.onRefreshListeners.push(boundedListener);
return new Disposable(remove);
}

private async processKeyValues(setting: ConfigurationSetting<string>): Promise<[string, unknown]> {
const [key, value] = await this.processAdapters(setting);
const trimmedKey = this.keyWithPrefixesTrimmed(key);
return [trimmedKey, value];
}

private async processAdapters(setting: ConfigurationSetting<string>): Promise<[string, unknown]> {
Expand All @@ -94,17 +179,13 @@ export class AzureAppConfigurationImpl extends Map<string, unknown> implements A
return key;
}

private enableRequestTracing() {
this.correlationContextHeader = createCorrelationContextHeader(this.options);
}

private customHeaders() {
private customHeaders(requestType: RequestType) {
if (!this.requestTracingEnabled) {
return undefined;
}

const headers = {};
headers[CorrelationContextHeaderName] = this.correlationContextHeader;
headers[CorrelationContextHeaderName] = createCorrelationContextHeader(this.options, requestType);
return headers;
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/AzureAppConfigurationOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { AppConfigurationClientOptions } from "@azure/app-configuration";
import { AzureAppConfigurationKeyVaultOptions } from "./keyvault/AzureAppConfigurationKeyVaultOptions";
import { RefreshOptions } from "./RefreshOptions";

export const MaxRetries = 2;
export const MaxRetryDelayInMs = 60000;
Expand Down Expand Up @@ -31,4 +32,8 @@ export interface AzureAppConfigurationOptions {
trimKeyPrefixes?: string[];
clientOptions?: AppConfigurationClientOptions;
keyVaultOptions?: AzureAppConfigurationKeyVaultOptions;
/**
* Specifies options for dynamic refresh key-values.
*/
refreshOptions?: RefreshOptions;
}
21 changes: 21 additions & 0 deletions src/RefreshOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { WatchedSetting } from "./WatchedSetting";

export const DefaultRefreshIntervalInMs = 30 * 1000;
export const MinimumRefreshIntervalInMs = 1 * 1000;

export interface RefreshOptions {
/**
* Specifies the interval for refresh to really update the values.
* 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;

/**
* Specifies settings to be watched, to determine whether the provider triggers a refresh.
*/
watchedSettings: WatchedSetting[];
}
7 changes: 7 additions & 0 deletions src/WatchedSetting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

export interface WatchedSetting {
key: string;
label?: string;
}
15 changes: 15 additions & 0 deletions src/common/disposable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

export class Disposable {
private disposed = false;
constructor(private callOnDispose: () => any) { }

dispose() {
if (!this.disposed) {
this.callOnDispose();
}
this.disposed = true;
}

}
Loading