Skip to content

Commit c8ab1c9

Browse files
committed
Support dynamic refresh
1 parent b4455bb commit c8ab1c9

File tree

12 files changed

+472
-21
lines changed

12 files changed

+472
-21
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+
}

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

4866
// validate selectors
@@ -55,23 +73,66 @@ export class AzureAppConfigurationImpl extends Map<string, unknown> implements A
5573
};
5674
if (this.requestTracingEnabled) {
5775
listOptions.requestOptions = {
58-
customHeaders: this.customHeaders()
76+
customHeaders: this.customHeaders(requestType)
5977
}
6078
}
6179

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

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

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

97-
private enableRequestTracing() {
98-
this.correlationContextHeader = createCorrelationContextHeader(this.options);
99-
}
100-
101-
private customHeaders() {
158+
private customHeaders(requestType: RequestType) {
102159
if (!this.requestTracingEnabled) {
103160
return undefined;
104161
}
105162

106163
const headers = {};
107-
headers[CorrelationContextHeaderName] = this.correlationContextHeader;
164+
headers[CorrelationContextHeaderName] = createCorrelationContextHeader(this.options, requestType);
108165
return headers;
109166
}
110167
}

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;
@@ -31,4 +32,8 @@ export interface AzureAppConfigurationOptions {
3132
trimKeyPrefixes?: string[];
3233
clientOptions?: AppConfigurationClientOptions;
3334
keyVaultOptions?: AzureAppConfigurationKeyVaultOptions;
35+
/**
36+
* Specifies options for dynamic refresh key-values.
37+
*/
38+
refreshOptions?: RefreshOptions;
3439
}

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+
}

src/common/disposable.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
export class Disposable {
5+
private disposed = false;
6+
constructor(private callOnDispose: () => any) { }
7+
8+
dispose() {
9+
if (!this.disposed) {
10+
this.callOnDispose();
11+
}
12+
this.disposed = true;
13+
}
14+
15+
}

0 commit comments

Comments
 (0)