From 03023cdaaec9de0590bdede550a68e71463381ce Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Mon, 22 Jun 2026 16:53:56 +0800 Subject: [PATCH] Added map to track pending key vault requests keyed by secret source id --- src/keyvault/keyVaultSecretProvider.ts | 20 +++- test/keyvault.test.ts | 157 +++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 4 deletions(-) diff --git a/src/keyvault/keyVaultSecretProvider.ts b/src/keyvault/keyVaultSecretProvider.ts index 2d5e113..d4077d6 100644 --- a/src/keyvault/keyVaultSecretProvider.ts +++ b/src/keyvault/keyVaultSecretProvider.ts @@ -13,6 +13,7 @@ export class AzureKeyVaultSecretProvider { #minSecretRefreshTimer: RefreshTimer; #secretClients: Map; // map key vault hostname to corresponding secret client #cachedSecretValues: Map = new Map(); // map secret identifier to secret value + #inflightRequests: Map> = new Map>(); // map secret identifier to in-flight Key Vault request constructor(keyVaultOptions: KeyVaultOptions | undefined, refreshTimer?: RefreshTimer) { if (keyVaultOptions?.secretRefreshIntervalInMs !== undefined) { @@ -42,10 +43,21 @@ export class AzureKeyVaultSecretProvider { return this.#cachedSecretValues.get(identifierKey); } - // Fallback to fetching the secret value from Key Vault. - const secretValue = await this.#getSecretValueFromKeyVault(secretIdentifier); - this.#cachedSecretValues.set(identifierKey, secretValue); - return secretValue; + // Deduplicate concurrent requests for the same secret: if a request is already in-flight, await it. + let pendingRequest = this.#inflightRequests.get(identifierKey); + if (pendingRequest === undefined) { + pendingRequest = this.#getSecretValueFromKeyVault(secretIdentifier) + .then((secretValue) => { + this.#cachedSecretValues.set(identifierKey, secretValue); + return secretValue; + }) + .finally(() => { + // Failures are not cached so subsequent calls can retry. + this.#inflightRequests.delete(identifierKey); + }); + this.#inflightRequests.set(identifierKey, pendingRequest); + } + return pendingRequest; } clearCache(): void { diff --git a/test/keyvault.test.ts b/test/keyvault.test.ts index feeb294..daa530c 100644 --- a/test/keyvault.test.ts +++ b/test/keyvault.test.ts @@ -142,6 +142,163 @@ 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 not cache failures and retry on a subsequent attempt", async () => { + mockDuplicateReferences(); + const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()); + const stub = sinon.stub(client, "getSecret"); + // The first (deduplicated) request rejects; the retry attempt succeeds. + // If the failure were cached, the retry would never succeed. + 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 + } + }); + + // First round: 5 concurrent references deduped to a single failing request. + // Second round (after load retry): a single succeeding request. + expect(stub.callCount).eq(2); + for (const key of ["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"]) { + expect(settings.get(key)).eq("SecretValue"); + } + }); + + 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++; + await sleepInMs(100); + 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"); + + // After the secret refresh interval elapses, the refresh round re-fetches once. + await sleepInMs(60_000 + 100); + await settings.refresh(); + expect(callCount).eq(2); + expect(settings.get("TestKey1")).eq("SecretValue-2"); + }); +}); + describe("key vault secret refresh", function () { beforeEach(() => {