11// Copyright (c) Microsoft Corporation.
22// Licensed under the MIT license.
33
4- import { AppConfigurationClient , ConfigurationSetting , ListConfigurationSettingsOptions } from "@azure/app-configuration" ;
4+ import { AppConfigurationClient , ConfigurationSetting , ConfigurationSettingId , GetConfigurationSettingOptions , GetConfigurationSettingResponse , ListConfigurationSettingsOptions } from "@azure/app-configuration" ;
5+ import { RestError } from "@azure/core-rest-pipeline" ;
56import { AzureAppConfiguration } from "./AzureAppConfiguration" ;
67import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions" ;
78import { IKeyValueAdapter } from "./IKeyValueAdapter" ;
89import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter" ;
9- import { KeyFilter , LabelFilter } from "./types" ;
10+ import { DefaultRefreshIntervalInMs , MinimumRefreshIntervalInMs } from "./RefreshOptions" ;
11+ import { Disposable } from "./common/disposable" ;
1012import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter" ;
13+ import { RefreshTimer } from "./refresh/RefreshTimer" ;
1114import { CorrelationContextHeaderName } from "./requestTracing/constants" ;
1215import { createCorrelationContextHeader , requestTracingEnabled } from "./requestTracing/utils" ;
13- import { SettingSelector } from "./types" ;
16+ import { KeyFilter , LabelFilter , SettingSelector } from "./types" ;
1417
15- export class AzureAppConfigurationImpl extends Map < string , unknown > implements AzureAppConfiguration {
18+ export class AzureAppConfigurationImpl extends Map < string , any > implements AzureAppConfiguration {
1619 #adapters: IKeyValueAdapter [ ] = [ ] ;
1720 /**
1821 * Trim key prefixes sorted in descending order.
1922 * Since multiple prefixes could start with the same characters, we need to trim the longest prefix first.
2023 */
2124 #sortedTrimKeyPrefixes: string [ ] | undefined ;
2225 readonly #requestTracingEnabled: boolean ;
23- #correlationContextHeader: string | undefined ;
2426 #client: AppConfigurationClient ;
2527 #options: AzureAppConfigurationOptions | undefined ;
28+ #isInitialLoadCompleted: boolean = false ;
29+
30+ // Refresh
31+ #refreshInterval: number = DefaultRefreshIntervalInMs ;
32+ #onRefreshListeners: Array < ( ) => any > = [ ] ;
33+ /**
34+ * Aka watched settings.
35+ */
36+ #sentinels: ConfigurationSettingId [ ] = [ ] ;
37+ #refreshTimer: RefreshTimer ;
2638
2739 constructor (
2840 client : AppConfigurationClient ,
@@ -34,21 +46,54 @@ export class AzureAppConfigurationImpl extends Map<string, unknown> implements A
3446
3547 // Enable request tracing if not opt-out
3648 this . #requestTracingEnabled = requestTracingEnabled ( ) ;
37- if ( this . #requestTracingEnabled) {
38- this . #enableRequestTracing( ) ;
39- }
4049
4150 if ( options ?. trimKeyPrefixes ) {
4251 this . #sortedTrimKeyPrefixes = [ ...options . trimKeyPrefixes ] . sort ( ( a , b ) => b . localeCompare ( a ) ) ;
4352 }
53+
54+ if ( options ?. refreshOptions ?. enabled ) {
55+ const { watchedSettings, refreshIntervalInMs } = options . refreshOptions ;
56+ // validate watched settings
57+ if ( watchedSettings === undefined || watchedSettings . length === 0 ) {
58+ throw new Error ( "Refresh is enabled but no watched settings are specified." ) ;
59+ }
60+
61+ // custom refresh interval
62+ if ( refreshIntervalInMs !== undefined ) {
63+ if ( refreshIntervalInMs < MinimumRefreshIntervalInMs ) {
64+ throw new Error ( `The refresh interval cannot be less than ${ MinimumRefreshIntervalInMs } milliseconds.` ) ;
65+
66+ } else {
67+ this . #refreshInterval = refreshIntervalInMs ;
68+ }
69+ }
70+
71+ for ( const setting of watchedSettings ) {
72+ if ( setting . key . includes ( "*" ) || setting . key . includes ( "," ) ) {
73+ throw new Error ( "The characters '*' and ',' are not supported in key of watched settings." ) ;
74+ }
75+ if ( setting . label ?. includes ( "*" ) || setting . label ?. includes ( "," ) ) {
76+ throw new Error ( "The characters '*' and ',' are not supported in label of watched settings." ) ;
77+ }
78+ this . #sentinels. push ( setting ) ;
79+ }
80+
81+ this . #refreshTimer = new RefreshTimer ( this . #refreshInterval) ;
82+ }
83+
4484 // TODO: should add more adapters to process different type of values
4585 // feature flag, others
4686 this . #adapters. push ( new AzureKeyVaultKeyValueAdapter ( options ?. keyVaultOptions ) ) ;
4787 this . #adapters. push ( new JsonKeyValueAdapter ( ) ) ;
4888 }
4989
50- async load ( ) {
51- const keyValues : [ key : string , value : unknown ] [ ] = [ ] ;
90+
91+ get #refreshEnabled( ) : boolean {
92+ return ! ! this . #options?. refreshOptions ?. enabled ;
93+ }
94+
95+ async #loadSelectedKeyValues( ) : Promise < ConfigurationSetting [ ] > {
96+ const loadedSettings : ConfigurationSetting [ ] = [ ] ;
5297
5398 // validate selectors
5499 const selectors = getValidSelectors ( this . #options?. selectors ) ;
@@ -60,25 +105,142 @@ export class AzureAppConfigurationImpl extends Map<string, unknown> implements A
60105 } ;
61106 if ( this . #requestTracingEnabled) {
62107 listOptions . requestOptions = {
63- customHeaders : this . #customHeaders( )
108+ customHeaders : {
109+ [ CorrelationContextHeaderName ] : createCorrelationContextHeader ( this . #options, this . #isInitialLoadCompleted)
110+ }
64111 }
65112 }
66113
67114 const settings = this . #client. listConfigurationSettings ( listOptions ) ;
68115
69116 for await ( const setting of settings ) {
70- if ( setting . key ) {
71- const [ key , value ] = await this . #processAdapters( setting ) ;
72- const trimmedKey = this . #keyWithPrefixesTrimmed( key ) ;
73- keyValues . push ( [ trimmedKey , value ] ) ;
117+ loadedSettings . push ( setting ) ;
118+ }
119+ }
120+ return loadedSettings ;
121+ }
122+
123+ /**
124+ * Update etag of watched settings from loaded data. If a watched setting is not covered by any selector, a request will be sent to retrieve it.
125+ */
126+ async #updateWatchedKeyValuesEtag( existingSettings : ConfigurationSetting [ ] ) : Promise < void > {
127+ if ( ! this . #refreshEnabled) {
128+ return ;
129+ }
130+
131+ for ( const sentinel of this . #sentinels) {
132+ const matchedSetting = existingSettings . find ( s => s . key === sentinel . key && s . label === sentinel . label ) ;
133+ if ( matchedSetting ) {
134+ sentinel . etag = matchedSetting . etag ;
135+ } else {
136+ // Send a request to retrieve key-value since it may be either not loaded or loaded with a different label or different casing
137+ const { key, label } = sentinel ;
138+ const response = await this . #getConfigurationSettingWithTrace( { key, label } ) ;
139+ if ( response ) {
140+ sentinel . etag = response . etag ;
141+ } else {
142+ sentinel . etag = undefined ;
74143 }
75144 }
76145 }
146+ }
147+
148+ async #loadSelectedAndWatchedKeyValues( ) {
149+ const keyValues : [ key : string , value : unknown ] [ ] = [ ] ;
150+
151+ const loadedSettings = await this . #loadSelectedKeyValues( ) ;
152+ await this . #updateWatchedKeyValuesEtag( loadedSettings ) ;
153+
154+ // process key-values, watched settings have higher priority
155+ for ( const setting of loadedSettings ) {
156+ const [ key , value ] = await this . #processKeyValues( setting ) ;
157+ keyValues . push ( [ key , value ] ) ;
158+ }
159+
160+ this . clear ( ) ; // clear existing key-values in case of configuration setting deletion
77161 for ( const [ k , v ] of keyValues ) {
78162 this . set ( k , v ) ;
79163 }
80164 }
81165
166+ /**
167+ * Load the configuration store for the first time.
168+ */
169+ async load ( ) {
170+ await this . #loadSelectedAndWatchedKeyValues( ) ;
171+
172+ // Mark all settings have loaded at startup.
173+ this . #isInitialLoadCompleted = true ;
174+ }
175+
176+ /**
177+ * Refresh the configuration store.
178+ */
179+ public async refresh ( ) : Promise < void > {
180+ if ( ! this . #refreshEnabled) {
181+ throw new Error ( "Refresh is not enabled." ) ;
182+ }
183+
184+ // if still within refresh interval/backoff, return
185+ if ( ! this . #refreshTimer. canRefresh ( ) ) {
186+ return Promise . resolve ( ) ;
187+ }
188+
189+ // try refresh if any of watched settings is changed.
190+ let needRefresh = false ;
191+ for ( const sentinel of this . #sentinels. values ( ) ) {
192+ const response = await this . #getConfigurationSettingWithTrace( sentinel , {
193+ onlyIfChanged : true
194+ } ) ;
195+
196+ if ( response ?. statusCode === 200 // created or changed
197+ || ( response === undefined && sentinel . etag !== undefined ) // deleted
198+ ) {
199+ sentinel . etag = response ?. etag ; // update etag of the sentinel
200+ needRefresh = true ;
201+ break ;
202+ }
203+ }
204+ if ( needRefresh ) {
205+ try {
206+ await this . #loadSelectedAndWatchedKeyValues( ) ;
207+ this . #refreshTimer. reset ( ) ;
208+ } catch ( error ) {
209+ // if refresh failed, backoff
210+ this . #refreshTimer. backoff ( ) ;
211+ throw error ;
212+ }
213+
214+ // successfully refreshed, run callbacks in async
215+ for ( const listener of this . #onRefreshListeners) {
216+ listener ( ) ;
217+ }
218+ }
219+ }
220+
221+ onRefresh ( listener : ( ) => any , thisArg ?: any ) : Disposable {
222+ if ( ! this . #refreshEnabled) {
223+ throw new Error ( "Refresh is not enabled." ) ;
224+ }
225+
226+ const boundedListener = listener . bind ( thisArg ) ;
227+ this . #onRefreshListeners. push ( boundedListener ) ;
228+
229+ const remove = ( ) => {
230+ const index = this . #onRefreshListeners. indexOf ( boundedListener ) ;
231+ if ( index >= 0 ) {
232+ this . #onRefreshListeners. splice ( index , 1 ) ;
233+ }
234+ }
235+ return new Disposable ( remove ) ;
236+ }
237+
238+ async #processKeyValues( setting : ConfigurationSetting < string > ) : Promise < [ string , unknown ] > {
239+ const [ key , value ] = await this . #processAdapters( setting ) ;
240+ const trimmedKey = this . #keyWithPrefixesTrimmed( key ) ;
241+ return [ trimmedKey , value ] ;
242+ }
243+
82244 async #processAdapters( setting : ConfigurationSetting < string > ) : Promise < [ string , unknown ] > {
83245 for ( const adapter of this . #adapters) {
84246 if ( adapter . canProcess ( setting ) ) {
@@ -99,18 +261,26 @@ export class AzureAppConfigurationImpl extends Map<string, unknown> implements A
99261 return key ;
100262 }
101263
102- #enableRequestTracing( ) {
103- this . #correlationContextHeader = createCorrelationContextHeader ( this . #options) ;
104- }
105-
106- #customHeaders( ) {
107- if ( ! this . #requestTracingEnabled) {
108- return undefined ;
264+ async #getConfigurationSettingWithTrace( configurationSettingId : ConfigurationSettingId , customOptions ?: GetConfigurationSettingOptions ) : Promise < GetConfigurationSettingResponse | undefined > {
265+ let response : GetConfigurationSettingResponse | undefined ;
266+ try {
267+ const options = { ...customOptions ?? { } } ;
268+ if ( this . #requestTracingEnabled) {
269+ options . requestOptions = {
270+ customHeaders : {
271+ [ CorrelationContextHeaderName ] : createCorrelationContextHeader ( this . #options, this . #isInitialLoadCompleted)
272+ }
273+ }
274+ }
275+ response = await this . #client. getConfigurationSetting ( configurationSettingId , options ) ;
276+ } catch ( error ) {
277+ if ( error instanceof RestError && error . statusCode === 404 ) {
278+ response = undefined ;
279+ } else {
280+ throw error ;
281+ }
109282 }
110-
111- const headers = { } ;
112- headers [ CorrelationContextHeaderName ] = this . #correlationContextHeader;
113- return headers ;
283+ return response ;
114284 }
115285}
116286
@@ -143,4 +313,4 @@ function getValidSelectors(selectors?: SettingSelector[]) {
143313 }
144314 return selector ;
145315 } ) ;
146- }
316+ }
0 commit comments