From 4f6d8b3269b8a5fa6bb1f91efd10ef7304ffdecf Mon Sep 17 00:00:00 2001 From: linglingye001 <143174321+linglingye001@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:25:28 +0800 Subject: [PATCH 1/3] refactor (#338) --- src/appConfigurationImpl.ts | 9 +++++---- src/common/utils.ts | 12 ++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index e247b314..a3d4834e 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -60,6 +60,7 @@ import { AIConfigurationTracingOptions } from "./requestTracing/aiConfigurationT import { KeyFilter, LabelFilter, SettingWatcher, SettingSelector, PagedSettingsWatcher, WatchedSetting } from "./types.js"; import { ConfigurationClientManager } from "./configurationClientManager.js"; import { getFixedBackoffDuration, getExponentialBackoffDuration } from "./common/backoffUtils.js"; +import { getStatusCode } from "./common/utils.js"; import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError, SnapshotReferenceError } from "./common/errors.js"; import { ErrorMessages } from "./common/errorMessages.js"; @@ -659,7 +660,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const watcher: SettingWatcher = this.#sentinels.get(watchedSetting)!; // watcher should always exist for sentinels const isDeleted = response === undefined && watcher.etag !== undefined; // previously existed, now deleted - const isChanged = response && Number(response.statusCode) === 200 && watcher.etag !== response.etag; // etag changed + const isChanged = response && getStatusCode(response.statusCode) === 200 && watcher.etag !== response.etag; // etag changed if (isDeleted || isChanged) { changedSentinel = watchedSetting; changedSentinelWatcher = { etag: isChanged ? response.etag : undefined }; @@ -750,7 +751,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { for await (const page of pageIterator) { // when conditional request is sent, the response will be 304 if not changed - if (Number(page._response.status) === 200) { // created or changed + if (getStatusCode(page._response.status) === 200) { // created or changed return true; } } @@ -779,7 +780,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { try { response = await this.#executeWithFailoverPolicy(funcToExecute); } catch (error) { - if (isRestError(error) && Number(error.statusCode) === 404) { + if (isRestError(error) && getStatusCode(error.statusCode) === 404) { response = undefined; } else { throw error; @@ -822,7 +823,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { try { response = await this.#executeWithFailoverPolicy(funcToExecute); } catch (error) { - if (isRestError(error) && Number(error.statusCode) === 404) { + if (isRestError(error) && getStatusCode(error.statusCode) === 404) { response = undefined; } else { throw error; diff --git a/src/common/utils.ts b/src/common/utils.ts index 18667874..9685c6ce 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -12,3 +12,15 @@ export function shuffleList(array: T[]): T[] { export function instanceOfTokenCredential(obj: unknown) { return obj && typeof obj === "object" && "getToken" in obj && typeof obj.getToken === "function"; } + +/** + * Normalizes an HTTP status code to a number. + * + * The underlying App Configuration client may surface the status code either as a number + * or as a string (e.g. "200", "304", "404") depending on the runtime/transport. This helper + * coerces the value to a number so that status code comparisons behave consistently and + * refresh logic is not broken when the status is a string. + */ +export function getStatusCode(statusCode: number | string | undefined): number { + return Number(statusCode); +} From ceef7564d1d8fd8a443b10646592155d802997d5 Mon Sep 17 00:00:00 2001 From: linglingye001 <143174321+linglingye001@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:55:50 +0800 Subject: [PATCH 2/3] Optimization for concurrent Key Vault Ref resolution - deduplicate requests to the same secret (#328) * Added map to track pending key vault requests keyed by secret source id * dedup key vault ref in preload stage * update * update * resolved comments * update * update --- src/appConfigurationImpl.ts | 47 ++---- src/common/errorMessages.ts | 12 ++ src/jsonKeyValueAdapter.ts | 6 +- src/keyValueAdapter.ts | 7 +- src/keyvault/keyVaultKeyValueAdapter.ts | 51 ++++-- src/keyvault/keyVaultSecretProvider.ts | 37 ++-- test/keyvault.test.ts | 214 ++++++++++++++++++++++++ 7 files changed, 309 insertions(+), 65 deletions(-) diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index a3d4834e..027b9cfa 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -111,7 +111,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { #secretRefreshEnabled: boolean = false; #secretReferences: ConfigurationSetting[] = []; // cached key vault references #secretRefreshTimer: RefreshTimer | undefined = undefined; - #resolveSecretsInParallel: boolean = false; /** * Selectors of key-values obtained from @see AzureAppConfigurationOptions.selectors @@ -203,7 +202,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { this.#secretRefreshEnabled = true; this.#secretRefreshTimer = new RefreshTimer(secretRefreshIntervalInMs); } - this.#resolveSecretsInParallel = options.keyVaultOptions.parallelSecretResolutionEnabled ?? false; } this.#adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions, this.#secretRefreshTimer)); this.#adapters.push(new JsonKeyValueAdapter()); @@ -559,22 +557,19 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { this.#aiConfigurationTracing.reset(); } + for (const adapter of this.#adapters) { + await adapter.preload?.(loadedSettings.filter(s => adapter.canProcess(s))); // dedup and warm the secret cache + } + for (const setting of loadedSettings) { if (isSecretReference(setting)) { this.#secretReferences.push(setting); // cache secret references for resolve/refresh secret separately - continue; } // adapt configuration settings to key-values const [key, value] = await this.#processKeyValue(setting); keyValues.push([key, value]); } - if (this.#secretReferences.length > 0) { - await this.#resolveSecretReferences(this.#secretReferences, (key, value) => { - keyValues.push([key, value]); - }); - } - this.#clearLoadedKeyValues(); // clear existing key-values in case of configuration setting deletion for (const [k, v] of keyValues) { this.#configMap.set(k, v); // reset the configuration @@ -672,7 +667,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (needRefresh) { for (const adapter of this.#adapters) { - await adapter.onChangeDetected(); + await adapter.onChangeDetected?.(); } await this.#loadSelectedKeyValues(); @@ -716,9 +711,16 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return Promise.resolve(false); } - await this.#resolveSecretReferences(this.#secretReferences, (key, value) => { + const keyVaultRefAdapter = this.#adapters.find(adapter => adapter instanceof AzureKeyVaultKeyValueAdapter) as AzureKeyVaultKeyValueAdapter | undefined; + if (keyVaultRefAdapter) { + // dedup and warm the secret cache + await keyVaultRefAdapter.preload(this.#secretReferences); + } + + for (const setting of this.#secretReferences) { + const [key, value] = await this.#processKeyValue(setting); this.#configMap.set(key, value); - }); + } this.#secretRefreshTimer.reset(); return Promise.resolve(true); @@ -894,27 +896,6 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { throw new Error(ErrorMessages.ALL_FALLBACK_CLIENTS_FAILED); } - async #resolveSecretReferences(secretReferences: ConfigurationSetting[], resultHandler: (key: string, value: unknown) => void): Promise { - if (this.#resolveSecretsInParallel) { - const secretResolutionPromises: Promise[] = []; - for (const setting of secretReferences) { - const secretResolutionPromise = this.#processKeyValue(setting) - .then(([key, value]) => { - resultHandler(key, value); - }); - secretResolutionPromises.push(secretResolutionPromise); - } - - // Wait for all secret resolution promises to be resolved - await Promise.all(secretResolutionPromises); - } else { - for (const setting of secretReferences) { - const [key, value] = await this.#processKeyValue(setting); - resultHandler(key, value); - } - } - } - async #processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { this.#setAIConfigurationTracing(setting); diff --git a/src/common/errorMessages.ts b/src/common/errorMessages.ts index e1f9f658..b838314f 100644 --- a/src/common/errorMessages.ts +++ b/src/common/errorMessages.ts @@ -3,6 +3,7 @@ import { MIN_REFRESH_INTERVAL_IN_MS } from "../refresh/refreshOptions.js"; import { MIN_SECRET_REFRESH_INTERVAL_IN_MS } from "../keyvault/keyVaultOptions.js"; +import { ConfigurationSetting } from "@azure/app-configuration"; export const enum ErrorMessages { INVALID_WATCHED_SETTINGS_KEY = "The characters '*' and ',' are not supported in key of watched settings.", @@ -26,3 +27,14 @@ export const enum KeyVaultReferenceErrorMessages { KEY_VAULT_OPTIONS_UNDEFINED = "Failed to process the Key Vault reference because Key Vault options are not configured.", KEY_VAULT_REFERENCE_UNRESOLVABLE = "Failed to resolve the key vault reference. No key vault secret client, credential or secret resolver callback is available to resolve the secret." } + +export function buildKeyVaultReferenceErrorMessage(message: string, secretIdentifier?: string, setting?: ConfigurationSetting): string { + let errorMessage = message; + if (secretIdentifier) { + errorMessage += ` SecretIdentifier: '${secretIdentifier}'`; + } + if (setting) { + errorMessage += ` Key: '${setting.key}' Label: '${setting.label ?? ""}' ETag: '${setting.etag ?? ""}'`; + } + return errorMessage; +} diff --git a/src/jsonKeyValueAdapter.ts b/src/jsonKeyValueAdapter.ts index 3ad03841..56735825 100644 --- a/src/jsonKeyValueAdapter.ts +++ b/src/jsonKeyValueAdapter.ts @@ -43,11 +43,7 @@ export class JsonKeyValueAdapter implements IKeyValueAdapter { return [setting.key, parsedValue]; } - async onChangeDetected(): Promise { - return; - } - - #tryParseJson(value: string): { success: true; result: unknown } | { success: false } { + #tryParseJson(value: string): { success: true; result: unknown } | { success: false } { try { return { success: true, result: JSON.parse(value) }; } catch (error) { diff --git a/src/keyValueAdapter.ts b/src/keyValueAdapter.ts index 222461dd..98573fc9 100644 --- a/src/keyValueAdapter.ts +++ b/src/keyValueAdapter.ts @@ -14,8 +14,13 @@ export interface IKeyValueAdapter { */ processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]>; + /** + * This method deduplicates and warms up Key Vault secret requests so that processKeyValue only reads from cache. + */ + preload?(settings: ConfigurationSetting[]): Promise; + /** * This method is called when a change is detected in the configuration setting. */ - onChangeDetected(): Promise; + onChangeDetected?(): Promise; } diff --git a/src/keyvault/keyVaultKeyValueAdapter.ts b/src/keyvault/keyVaultKeyValueAdapter.ts index 2e8eb4d0..60a01244 100644 --- a/src/keyvault/keyVaultKeyValueAdapter.ts +++ b/src/keyvault/keyVaultKeyValueAdapter.ts @@ -7,10 +7,8 @@ import { AzureKeyVaultSecretProvider } from "./keyVaultSecretProvider.js"; import { KeyVaultOptions } from "./keyVaultOptions.js"; import { RefreshTimer } from "../refresh/refreshTimer.js"; import { ArgumentError, KeyVaultReferenceError } from "../common/errors.js"; -import { KeyVaultReferenceErrorMessages } from "../common/errorMessages.js"; +import { KeyVaultReferenceErrorMessages, buildKeyVaultReferenceErrorMessage } from "../common/errorMessages.js"; import { KeyVaultSecretIdentifier, parseKeyVaultSecretIdentifier } from "@azure/keyvault-secrets"; -import { isRestError } from "@azure/core-rest-pipeline"; -import { AuthenticationError } from "@azure/identity"; export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { #keyVaultOptions: KeyVaultOptions | undefined; @@ -35,17 +33,44 @@ export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { parseSecretReference(setting).value.secretId ); } catch (error) { - throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage("Invalid Key Vault reference.", setting), { cause: error }); + throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage("Invalid Key Vault reference.", undefined, setting), { cause: error }); } - try { - const secretValue = await this.#keyVaultSecretProvider.getSecretValue(secretIdentifier); - return [setting.key, secretValue]; - } catch (error) { - if (isRestError(error) || error instanceof AuthenticationError) { - throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage("Failed to resolve Key Vault reference.", setting, secretIdentifier.sourceId), { cause: error }); + const secretValue = await this.#keyVaultSecretProvider.getSecretValue(secretIdentifier); + return [setting.key, secretValue]; + } + + async preload(settings: ConfigurationSetting[]): Promise { + if (!this.#keyVaultOptions) { + return; // no-op when keyVaultOptions is not configured + } + // Deduplicate references by secret identifier (sourceId). + const uniqueSecrets = new Map(); + for (const setting of settings) { + if (!this.canProcess(setting)) { + continue; + } + let secretIdentifier: KeyVaultSecretIdentifier; + try { + secretIdentifier = parseKeyVaultSecretIdentifier( + parseSecretReference(setting).value.secretId + ); + } catch (error) { + throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage("Invalid Key Vault reference.", undefined, setting), { cause: error }); + } + if (!uniqueSecrets.has(secretIdentifier.sourceId)) { + uniqueSecrets.set(secretIdentifier.sourceId, secretIdentifier); + } + } + + // Resolve failures surface as KeyVaultReferenceError from the provider, identified by secret identifier. + const uniqueSecretIdentifiers = [...uniqueSecrets.values()]; + if (this.#keyVaultOptions.parallelSecretResolutionEnabled) { + await Promise.all(uniqueSecretIdentifiers.map(secretIdentifier => this.#keyVaultSecretProvider.loadSecretValue(secretIdentifier))); + } else { + for (const secretIdentifier of uniqueSecretIdentifiers) { + await this.#keyVaultSecretProvider.loadSecretValue(secretIdentifier); } - throw error; } } @@ -54,7 +79,3 @@ export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { return; } } - -function buildKeyVaultReferenceErrorMessage(message: string, setting: ConfigurationSetting, secretIdentifier?: string ): string { - return `${message} Key: '${setting.key}' Label: '${setting.label ?? ""}' ETag: '${setting.etag ?? ""}' ${secretIdentifier ? ` SecretIdentifier: '${secretIdentifier}'` : ""}`; -} diff --git a/src/keyvault/keyVaultSecretProvider.ts b/src/keyvault/keyVaultSecretProvider.ts index fa3d825b..663d1cef 100644 --- a/src/keyvault/keyVaultSecretProvider.ts +++ b/src/keyvault/keyVaultSecretProvider.ts @@ -3,9 +3,11 @@ import { KeyVaultOptions, MIN_SECRET_REFRESH_INTERVAL_IN_MS } from "./keyVaultOptions.js"; import { RefreshTimer } from "../refresh/refreshTimer.js"; -import { ArgumentError } from "../common/errors.js"; +import { ArgumentError, KeyVaultReferenceError } from "../common/errors.js"; import { SecretClient, KeyVaultSecretIdentifier } from "@azure/keyvault-secrets"; -import { KeyVaultReferenceErrorMessages } from "../common/errorMessages.js"; +import { KeyVaultReferenceErrorMessages, buildKeyVaultReferenceErrorMessage } from "../common/errorMessages.js"; +import { isRestError } from "@azure/core-rest-pipeline"; +import { AuthenticationError } from "@azure/identity"; export class AzureKeyVaultSecretProvider { #keyVaultOptions: KeyVaultOptions | undefined; @@ -33,21 +35,34 @@ export class AzureKeyVaultSecretProvider { } } - async getSecretValue(secretIdentifier: KeyVaultSecretIdentifier): Promise { + async loadSecretValue(secretIdentifier: KeyVaultSecretIdentifier): Promise { const identifierKey = secretIdentifier.sourceId; - - // If the refresh interval is not expired, return the cached value if available. - if (this.#cachedSecretValues.has(identifierKey) && - (!this.#secretRefreshTimer || !this.#secretRefreshTimer.canRefresh())) { - return this.#cachedSecretValues.get(identifierKey); + const shouldRefresh = this.#secretRefreshTimer?.canRefresh() ?? false; + if (this.#cachedSecretValues.has(identifierKey) && !shouldRefresh) { + return this.#cachedSecretValues.get(identifierKey); // already cached and still fresh + } + let secretValue: unknown; + try { + secretValue = await this.#getSecretValueFromKeyVault(secretIdentifier); + } catch (error) { + if (isRestError(error) || error instanceof AuthenticationError) { + throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage("Failed to resolve Key Vault reference.", secretIdentifier.sourceId), { cause: error }); + } + throw error; } - - // Fallback to fetching the secret value from Key Vault. - const secretValue = await this.#getSecretValueFromKeyVault(secretIdentifier); this.#cachedSecretValues.set(identifierKey, secretValue); return secretValue; } + // Serves the secret value from cache when available; otherwise loads it from Key Vault on demand. + async getSecretValue(secretIdentifier: KeyVaultSecretIdentifier): Promise { + const identifierKey = secretIdentifier.sourceId; + if (this.#cachedSecretValues.has(identifierKey)) { + return this.#cachedSecretValues.get(identifierKey); // serve from cache + } + return this.loadSecretValue(secretIdentifier); // fallback: load on demand + } + clearCache(): void { if (this.#minSecretRefreshTimer.canRefresh()) { this.#cachedSecretValues.clear(); diff --git a/test/keyvault.test.ts b/test/keyvault.test.ts index feeb2941..17b6e1a0 100644 --- a/test/keyvault.test.ts +++ b/test/keyvault.test.ts @@ -142,6 +142,220 @@ describe("key vault reference", function () { }); }); +describe("key vault reference deduplication", function () { + afterEach(() => { + restoreMocks(); + }); + + // 5 settings all referencing the same secret URI (same sourceId). + const sameSecretUri = "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName"; + function mockDuplicateReferences() { + const kvs = ["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"] + .map((key) => createMockedKeyVaultReference(key, sameSecretUri)); + mockAppConfigurationClientListConfigurationSettings([kvs]); + } + + it("should resolve duplicate references with a single Key Vault request in parallel mode", async () => { + mockDuplicateReferences(); + const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()); + const stub = sinon.stub(client, "getSecret").callsFake(async () => { + // Introduce a delay so that all references start before the first one resolves. + await sleepInMs(100); + return { value: "SecretValue" } as KeyVaultSecret; + }); + + const settings = await load(createMockedConnectionString(), { + keyVaultOptions: { + secretClients: [client], + parallelSecretResolutionEnabled: true + } + }); + + expect(stub.callCount).eq(1); + for (const key of ["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"]) { + expect(settings.get(key)).eq("SecretValue"); + } + }); + + it("should resolve duplicate references with a single Key Vault request in sequential mode", async () => { + mockDuplicateReferences(); + const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()); + const stub = sinon.stub(client, "getSecret").callsFake(async () => { + return { value: "SecretValue" } as KeyVaultSecret; + }); + + const settings = await load(createMockedConnectionString(), { + keyVaultOptions: { + secretClients: [client] + } + }); + + expect(stub.callCount).eq(1); + for (const key of ["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"]) { + expect(settings.get(key)).eq("SecretValue"); + } + }); + + it("should invoke secret resolver only once for duplicate references", async () => { + mockDuplicateReferences(); + const resolver = sinon.stub().callsFake(async () => { + await sleepInMs(100); + return "ResolvedSecretValue"; + }); + + const settings = await load(createMockedConnectionString(), { + keyVaultOptions: { + secretResolver: resolver, + parallelSecretResolutionEnabled: true + } + }); + + expect(resolver.callCount).eq(1); + for (const key of ["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"]) { + expect(settings.get(key)).eq("ResolvedSecretValue"); + } + }); + + it("should fetch different versions of the same secret independently", async () => { + const versionedUri = "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName/741a0fc52610449baffd6e1c55b9d459"; + const kvs = [ + createMockedKeyVaultReference("TestKey", sameSecretUri), + createMockedKeyVaultReference("TestKeyVersioned", versionedUri) + ]; + mockAppConfigurationClientListConfigurationSettings([kvs]); + const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()); + const stub = sinon.stub(client, "getSecret").callsFake(async (_name, options) => { + await sleepInMs(100); + return { value: options?.version ? "VersionedValue" : "LatestValue" } as KeyVaultSecret; + }); + + const settings = await load(createMockedConnectionString(), { + keyVaultOptions: { + secretClients: [client], + parallelSecretResolutionEnabled: true + } + }); + + expect(stub.callCount).eq(2); + expect(settings.get("TestKey")).eq("LatestValue"); + expect(settings.get("TestKeyVersioned")).eq("VersionedValue"); + }); + + it("should recover and not cache the failure when preload fails to fetch a secret", async () => { + mockDuplicateReferences(); + const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()); + const stub = sinon.stub(client, "getSecret"); + // The preload fetch (best-effort) rejects and must not be cached; the on-demand resolution then + // recovers by re-fetching successfully. + stub.onCall(0).callsFake(async () => { + await sleepInMs(100); + throw new Error("Key Vault unavailable"); + }); + stub.callsFake(async () => { + return { value: "SecretValue" } as KeyVaultSecret; + }); + + const settings = await load(createMockedConnectionString(), { + keyVaultOptions: { + secretClients: [client], + parallelSecretResolutionEnabled: true + } + }); + + // The failed preload is not cached, so all references still resolve successfully. + for (const key of ["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"]) { + expect(settings.get(key)).eq("SecretValue"); + } + }); + + it("should re-fetch once per unique secret on a key-value change reload", async () => { + const secretKeys = ["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"]; + // Mutable page so the sentinel can change without re-stubbing (which would tear down the fake clock). + const kvPage = [ + ...secretKeys.map((key) => createMockedKeyVaultReference(key, sameSecretUri)), + createMockedKeyValue({ key: "sentinel", value: "v1", etag: "sentinel-etag-1" }) + ]; + mockAppConfigurationClientListConfigurationSettings([kvPage]); + mockAppConfigurationClientGetConfigurationSetting(kvPage); + + const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()); + let secretValue = "SecretValue-initial"; + let callCount = 0; + sinon.stub(client, "getSecret").callsFake(async () => { + callCount++; + return { value: secretValue } as KeyVaultSecret; + }); + + const settings = await load(createMockedConnectionString(), { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 1000, + watchedSettings: [{ key: "sentinel" }] + }, + keyVaultOptions: { + secretClients: [client], + parallelSecretResolutionEnabled: true + } + }); + // Initial load resolves the duplicates with a single request. + expect(callCount).eq(1); + + // Install the fake clock seeded with the current time so the refresh timers (created during load) stay consistent. + const clock = sinon.useFakeTimers({ now: Date.now() }); + try { + // Advance past the min secret refresh interval so the key-value change reload clears the cache. + await clock.tickAsync(60_000 + 100); + + // Change the watched sentinel (new etag) and the secret value in place to trigger a reload. + kvPage[kvPage.length - 1] = createMockedKeyValue({ key: "sentinel", value: "v2", etag: "sentinel-etag-2" }); + secretValue = "SecretValue-reloaded"; + const callCountBeforeReload = callCount; + + await settings.refresh(); + + // The key-value change reload clears the cache and re-fetches, but only once for the unique secret. + expect(callCount - callCountBeforeReload).eq(1); + for (const key of secretKeys) { + expect(settings.get(key)).eq("SecretValue-reloaded"); + } + } finally { + clock.restore(); + } + }); + + it("should re-fetch once per unique secret on each refresh round", async () => { + mockDuplicateReferences(); + const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()); + let callCount = 0; + sinon.stub(client, "getSecret").callsFake(async () => { + callCount++; + return { value: `SecretValue-${callCount}` } as KeyVaultSecret; + }); + + const settings = await load(createMockedConnectionString(), { + keyVaultOptions: { + secretClients: [client], + secretRefreshIntervalInMs: 60_000, + parallelSecretResolutionEnabled: true + } + }); + // Initial load resolves duplicates with a single request. + expect(callCount).eq(1); + expect(settings.get("TestKey1")).eq("SecretValue-1"); + + // Advance past the secret refresh interval using a fake clock so the refresh round re-fetches once. + const clock = sinon.useFakeTimers({ now: Date.now() }); + try { + await clock.tickAsync(60_000 + 100); + await settings.refresh(); + } finally { + clock.restore(); + } + expect(callCount).eq(2); + expect(settings.get("TestKey1")).eq("SecretValue-2"); + }); +}); + describe("key vault secret refresh", function () { beforeEach(() => { From 216bad44aaf9ddf80554251778fa4429a20c1f6a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:18:39 +0800 Subject: [PATCH 3/3] Bump package version to 2.6.0 (#342) * Initial plan * Bump package version to 2.6.0 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- package-lock.json | 4 ++-- package.json | 2 +- src/version.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 30cbd476..9d16248d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@azure/app-configuration-provider", - "version": "2.5.1", + "version": "2.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@azure/app-configuration-provider", - "version": "2.5.1", + "version": "2.6.0", "license": "MIT", "dependencies": { "@azure/app-configuration": "^1.11.0", diff --git a/package.json b/package.json index cddad016..7e906a28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azure/app-configuration-provider", - "version": "2.5.1", + "version": "2.6.0", "description": "The JavaScript configuration provider for Azure App Configuration", "files": [ "dist/", diff --git a/src/version.ts b/src/version.ts index 6428e512..cd2a07aa 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export const VERSION = "2.5.1"; +export const VERSION = "2.6.0";