Skip to content

Commit 780d1b3

Browse files
committed
Support dynamic refresh
1 parent 582cd1a commit 780d1b3

15 files changed

+534
-58
lines changed

.eslintrc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,6 @@
4040
"avoidEscape": true
4141
}
4242
],
43-
},
43+
"@typescript-eslint/no-explicit-any": "off"
44+
}
4445
}

examples/refresh.mjs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import * as dotenv from "dotenv";
5+
import { promisify } from "util";
6+
dotenv.config();
7+
const sleepInMs = promisify(setTimeout);
8+
9+
/**
10+
* This example retrives all settings with key following pattern "app.settings.*", i.e. starting with "app.settings.".
11+
* With the option `trimKeyPrefixes`, it trims the prefix "app.settings." from keys for simplicity.
12+
* Value of config "app.settings.message" will be printed.
13+
*
14+
* Below environment variables are required for this example:
15+
* - APPCONFIG_CONNECTION_STRING
16+
*/
17+
18+
import { load } from "@azure/app-configuration-provider";
19+
const connectionString = process.env.APPCONFIG_CONNECTION_STRING;
20+
const settings = await load(connectionString, {
21+
selectors: [{
22+
keyFilter: "app.settings.*"
23+
}],
24+
trimKeyPrefixes: ["app.settings."],
25+
refreshOptions: {
26+
watchedSettings: [{ key: "app.settings.sentinel" }],
27+
refreshIntervalInMs: 10 * 1000 // Default value is 30 seconds, shorted for this sample
28+
}
29+
});
30+
31+
console.log("Update the `message` in your App Configuration store using Azure portal or CLI.")
32+
console.log("First, update the `message` value, and then update the `sentinel` key value.")
33+
34+
while (true) {
35+
// Refreshing the configuration setting
36+
await settings.refresh();
37+
38+
// Current value of message
39+
console.log(settings.get("message"));
40+
41+
// Waiting before the next refresh
42+
await sleepInMs(5000);
43+
}

package-lock.json

Lines changed: 44 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@
4747
"rollup-plugin-dts": "^5.3.0",
4848
"sinon": "^15.2.0",
4949
"tslib": "^2.6.0",
50-
"typescript": "^5.1.6"
50+
"typescript": "^5.1.6",
51+
"uuid": "^9.0.1"
5152
},
5253
"dependencies": {
5354
"@azure/app-configuration": "^1.4.1",

src/AzureAppConfiguration.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33

4+
import { Disposable } from "./common/disposable";
5+
46
export type AzureAppConfiguration = {
5-
// methods for advanced features, e.g. refresh()
6-
} & ReadonlyMap<string, unknown>;
7+
/**
8+
* API to trigger refresh operation.
9+
*/
10+
refresh(): Promise<void>;
11+
12+
/**
13+
* API to register callback listeners, which will be called only when a refresh operation successfully updates key-values.
14+
*
15+
* @param listener Callback funtion to be registered.
16+
* @param thisArg Optional. Value to use as this when executing callback.
17+
*/
18+
onRefresh(listener: () => any, thisArg?: any): Disposable;
19+
} & ReadonlyMap<string, any>;

src/AzureAppConfigurationImpl.ts

Lines changed: 73 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter";
99
import { KeyFilter } from "./KeyFilter";
1010
import { LabelFilter } from "./LabelFilter";
1111
import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter";
12-
import { CorrelationContextHeaderName } from "./requestTracing/constants";
12+
import { CorrelationContextHeaderName, RequestType } from "./requestTracing/constants";
1313
import { createCorrelationContextHeader, requestTracingEnabled } from "./requestTracing/utils";
14+
import { DefaultRefreshIntervalInMs, MinimumRefreshIntervalInMs } from "./RefreshOptions";
15+
import { LinkedList } from "./common/linkedList";
16+
import { Disposable } from "./common/disposable";
1417

1518
export class AzureAppConfigurationImpl extends Map<string, unknown> implements AzureAppConfiguration {
1619
private adapters: IKeyValueAdapter[] = [];
@@ -20,7 +23,10 @@ export class AzureAppConfigurationImpl extends Map<string, unknown> implements A
2023
*/
2124
private sortedTrimKeyPrefixes: string[] | undefined;
2225
private readonly requestTracingEnabled: boolean;
23-
private correlationContextHeader: string | undefined;
26+
// Refresh
27+
private refreshIntervalInMs: number;
28+
private onRefreshListeners: LinkedList<() => any>;
29+
private lastUpdateTimestamp: number;
2430

2531
constructor(
2632
private client: AppConfigurationClient,
@@ -29,20 +35,32 @@ export class AzureAppConfigurationImpl extends Map<string, unknown> implements A
2935
super();
3036
// Enable request tracing if not opt-out
3137
this.requestTracingEnabled = requestTracingEnabled();
32-
if (this.requestTracingEnabled) {
33-
this.enableRequestTracing();
34-
}
3538

3639
if (options?.trimKeyPrefixes) {
3740
this.sortedTrimKeyPrefixes = [...options.trimKeyPrefixes].sort((a, b) => b.localeCompare(a));
3841
}
42+
43+
if (options?.refreshOptions) {
44+
this.onRefreshListeners = new LinkedList();
45+
this.refreshIntervalInMs = DefaultRefreshIntervalInMs;
46+
47+
const refreshIntervalInMs = this.options?.refreshOptions?.refreshIntervalInMs;
48+
if (refreshIntervalInMs !== undefined) {
49+
if (refreshIntervalInMs < MinimumRefreshIntervalInMs) {
50+
throw new Error(`The refresh interval time cannot be less than ${MinimumRefreshIntervalInMs} milliseconds.`);
51+
} else {
52+
this.refreshIntervalInMs = refreshIntervalInMs;
53+
}
54+
}
55+
}
56+
3957
// TODO: should add more adapters to process different type of values
4058
// feature flag, others
4159
this.adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions));
4260
this.adapters.push(new JsonKeyValueAdapter());
4361
}
4462

45-
public async load() {
63+
public async load(requestType: RequestType = RequestType.Startup) {
4664
const keyValues: [key: string, value: unknown][] = [];
4765
const selectors = this.options?.selectors ?? [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }];
4866
for (const selector of selectors) {
@@ -52,23 +70,66 @@ export class AzureAppConfigurationImpl extends Map<string, unknown> implements A
5270
};
5371
if (this.requestTracingEnabled) {
5472
listOptions.requestOptions = {
55-
customHeaders: this.customHeaders()
73+
customHeaders: this.customHeaders(requestType)
5674
}
5775
}
5876

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

6179
for await (const setting of settings) {
6280
if (setting.key) {
63-
const [key, value] = await this.processAdapters(setting);
64-
const trimmedKey = this.keyWithPrefixesTrimmed(key);
65-
keyValues.push([trimmedKey, value]);
81+
const keyValuePair = await this.processKeyValues(setting);
82+
keyValues.push(keyValuePair);
6683
}
6784
}
6885
}
6986
for (const [k, v] of keyValues) {
7087
this.set(k, v);
7188
}
89+
this.lastUpdateTimestamp = Date.now();
90+
}
91+
92+
public async refresh(): Promise<void> {
93+
// if no refreshOptions set, return
94+
if (this.options?.refreshOptions === undefined || this.options.refreshOptions.watchedSettings.length === 0) {
95+
return Promise.resolve();
96+
}
97+
// if still within refresh interval, return
98+
const now = Date.now();
99+
if (now < this.lastUpdateTimestamp + this.refreshIntervalInMs) {
100+
return Promise.resolve();
101+
}
102+
103+
// try refresh if any of watched settings is changed.
104+
// TODO: watchedSettings as optional, etag based refresh if not specified.
105+
let needRefresh = false;
106+
for (const watchedSetting of this.options.refreshOptions.watchedSettings) {
107+
const response = await this.client.getConfigurationSetting(watchedSetting);
108+
const [key, value] = await this.processKeyValues(response);
109+
if (value !== this.get(key)) {
110+
needRefresh = true;
111+
break;
112+
}
113+
}
114+
if (needRefresh) {
115+
await this.load(RequestType.Watch);
116+
// run callbacks in async
117+
for (const listener of this.onRefreshListeners) {
118+
listener();
119+
}
120+
}
121+
}
122+
123+
public onRefresh(listener: () => any, thisArg?: any): Disposable {
124+
const boundedListener = listener.bind(thisArg);
125+
const remove = this.onRefreshListeners.push(boundedListener);
126+
return new Disposable(remove);
127+
}
128+
129+
private async processKeyValues(setting: ConfigurationSetting<string>): Promise<[string, unknown]> {
130+
const [key, value] = await this.processAdapters(setting);
131+
const trimmedKey = this.keyWithPrefixesTrimmed(key);
132+
return [trimmedKey, value];
72133
}
73134

74135
private async processAdapters(setting: ConfigurationSetting<string>): Promise<[string, unknown]> {
@@ -91,17 +152,13 @@ export class AzureAppConfigurationImpl extends Map<string, unknown> implements A
91152
return key;
92153
}
93154

94-
private enableRequestTracing() {
95-
this.correlationContextHeader = createCorrelationContextHeader(this.options);
96-
}
97-
98-
private customHeaders() {
155+
private customHeaders(requestType: RequestType) {
99156
if (!this.requestTracingEnabled) {
100157
return undefined;
101158
}
102159

103160
const headers = {};
104-
headers[CorrelationContextHeaderName] = this.correlationContextHeader;
161+
headers[CorrelationContextHeaderName] = createCorrelationContextHeader(this.options, requestType);
105162
return headers;
106163
}
107164
}

src/AzureAppConfigurationOptions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import { AppConfigurationClientOptions } from "@azure/app-configuration";
55
import { AzureAppConfigurationKeyVaultOptions } from "./keyvault/AzureAppConfigurationKeyVaultOptions";
6+
import { RefreshOptions } from "./RefreshOptions";
67

78
export const MaxRetries = 2;
89
export const MaxRetryDelayInMs = 60000;
@@ -12,4 +13,8 @@ export interface AzureAppConfigurationOptions {
1213
trimKeyPrefixes?: string[];
1314
clientOptions?: AppConfigurationClientOptions;
1415
keyVaultOptions?: AzureAppConfigurationKeyVaultOptions;
16+
/**
17+
* Specifies options for dynamic refresh key-values.
18+
*/
19+
refreshOptions?: RefreshOptions;
1520
}

src/RefreshOptions.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { WatchedSetting } from "./WatchedSetting";
5+
6+
export const DefaultRefreshIntervalInMs = 30 * 1000;
7+
export const MinimumRefreshIntervalInMs = 1 * 1000;
8+
9+
export interface RefreshOptions {
10+
/**
11+
* Specifies the interval for refresh to really update the values.
12+
* Default value is 30 seconds. Must be greater than 1 second.
13+
* Any refresh operation triggered will not update the value for a key until after the interval.
14+
*/
15+
refreshIntervalInMs?: number;
16+
17+
/**
18+
* Specifies settings to be watched, to determine whether the provider triggers a refresh.
19+
*/
20+
watchedSettings: WatchedSetting[];
21+
}

src/WatchedSetting.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
export interface WatchedSetting {
5+
key: string;
6+
label?: string;
7+
}

0 commit comments

Comments
 (0)