Skip to content

Commit

Permalink
Support dynamic refresh
Browse files Browse the repository at this point in the history
  • Loading branch information
Eskibear committed Oct 26, 2023
1 parent 582cd1a commit 892adf6
Show file tree
Hide file tree
Showing 14 changed files with 488 additions and 58 deletions.
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"
}
}
51 changes: 44 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@
"rollup-plugin-dts": "^5.3.0",
"sinon": "^15.2.0",
"tslib": "^2.6.0",
"typescript": "^5.1.6"
"typescript": "^5.1.6",
"uuid": "^9.0.1"
},
"dependencies": {
"@azure/app-configuration": "^1.4.1",
Expand Down
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>;
89 changes: 73 additions & 16 deletions src/AzureAppConfigurationImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ 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,10 @@ 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;
private onRefreshListeners: LinkedList<() => any>;
private lastUpdateTimestamp: number;

constructor(
private client: AppConfigurationClient,
Expand All @@ -29,20 +35,32 @@ 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;
}
}
}

// 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][] = [];
const selectors = this.options?.selectors ?? [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }];
for (const selector of selectors) {
Expand All @@ -52,23 +70,66 @@ 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);
}
}
}
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.options?.refreshOptions === undefined || this.options.refreshOptions.watchedSettings.length === 0) {
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.
// TODO: watchedSettings as optional, etag based refresh if not specified.
let needRefresh = false;
for (const watchedSetting of this.options.refreshOptions.watchedSettings) {
const response = await this.client.getConfigurationSetting(watchedSetting);
const [key, value] = await this.processKeyValues(response);
if (value !== this.get(key)) {
needRefresh = true;
break;
}
}
if (needRefresh) {
await this.load(RequestType.Watch);
// run callbacks in async
for (const listener of this.onRefreshListeners) {
listener();
}
}
}

public onRefresh(listener: () => any, thisArg?: any): Disposable {
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 @@ -91,17 +152,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;
}
}
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 All @@ -12,4 +13,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

0 comments on commit 892adf6

Please sign in to comment.