Skip to content

Commit 03023cd

Browse files
committed
Added map to track pending key vault requests keyed by secret source id
1 parent 365bbfc commit 03023cd

2 files changed

Lines changed: 173 additions & 4 deletions

File tree

src/keyvault/keyVaultSecretProvider.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export class AzureKeyVaultSecretProvider {
1313
#minSecretRefreshTimer: RefreshTimer;
1414
#secretClients: Map<string, SecretClient>; // map key vault hostname to corresponding secret client
1515
#cachedSecretValues: Map<string, any> = new Map<string, any>(); // map secret identifier to secret value
16+
#inflightRequests: Map<string, Promise<unknown>> = new Map<string, Promise<unknown>>(); // map secret identifier to in-flight Key Vault request
1617

1718
constructor(keyVaultOptions: KeyVaultOptions | undefined, refreshTimer?: RefreshTimer) {
1819
if (keyVaultOptions?.secretRefreshIntervalInMs !== undefined) {
@@ -42,10 +43,21 @@ export class AzureKeyVaultSecretProvider {
4243
return this.#cachedSecretValues.get(identifierKey);
4344
}
4445

45-
// Fallback to fetching the secret value from Key Vault.
46-
const secretValue = await this.#getSecretValueFromKeyVault(secretIdentifier);
47-
this.#cachedSecretValues.set(identifierKey, secretValue);
48-
return secretValue;
46+
// Deduplicate concurrent requests for the same secret: if a request is already in-flight, await it.
47+
let pendingRequest = this.#inflightRequests.get(identifierKey);
48+
if (pendingRequest === undefined) {
49+
pendingRequest = this.#getSecretValueFromKeyVault(secretIdentifier)
50+
.then((secretValue) => {
51+
this.#cachedSecretValues.set(identifierKey, secretValue);
52+
return secretValue;
53+
})
54+
.finally(() => {
55+
// Failures are not cached so subsequent calls can retry.
56+
this.#inflightRequests.delete(identifierKey);
57+
});
58+
this.#inflightRequests.set(identifierKey, pendingRequest);
59+
}
60+
return pendingRequest;
4961
}
5062

5163
clearCache(): void {

test/keyvault.test.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,163 @@ describe("key vault reference", function () {
142142
});
143143
});
144144

145+
describe("key vault reference deduplication", function () {
146+
afterEach(() => {
147+
restoreMocks();
148+
});
149+
150+
// 5 settings all referencing the same secret URI (same sourceId).
151+
const sameSecretUri = "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName";
152+
function mockDuplicateReferences() {
153+
const kvs = ["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"]
154+
.map((key) => createMockedKeyVaultReference(key, sameSecretUri));
155+
mockAppConfigurationClientListConfigurationSettings([kvs]);
156+
}
157+
158+
it("should resolve duplicate references with a single Key Vault request in parallel mode", async () => {
159+
mockDuplicateReferences();
160+
const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential());
161+
const stub = sinon.stub(client, "getSecret").callsFake(async () => {
162+
// Introduce a delay so that all references start before the first one resolves.
163+
await sleepInMs(100);
164+
return { value: "SecretValue" } as KeyVaultSecret;
165+
});
166+
167+
const settings = await load(createMockedConnectionString(), {
168+
keyVaultOptions: {
169+
secretClients: [client],
170+
parallelSecretResolutionEnabled: true
171+
}
172+
});
173+
174+
expect(stub.callCount).eq(1);
175+
for (const key of ["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"]) {
176+
expect(settings.get(key)).eq("SecretValue");
177+
}
178+
});
179+
180+
it("should resolve duplicate references with a single Key Vault request in sequential mode", async () => {
181+
mockDuplicateReferences();
182+
const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential());
183+
const stub = sinon.stub(client, "getSecret").callsFake(async () => {
184+
return { value: "SecretValue" } as KeyVaultSecret;
185+
});
186+
187+
const settings = await load(createMockedConnectionString(), {
188+
keyVaultOptions: {
189+
secretClients: [client]
190+
}
191+
});
192+
193+
expect(stub.callCount).eq(1);
194+
for (const key of ["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"]) {
195+
expect(settings.get(key)).eq("SecretValue");
196+
}
197+
});
198+
199+
it("should invoke secret resolver only once for duplicate references", async () => {
200+
mockDuplicateReferences();
201+
const resolver = sinon.stub().callsFake(async () => {
202+
await sleepInMs(100);
203+
return "ResolvedSecretValue";
204+
});
205+
206+
const settings = await load(createMockedConnectionString(), {
207+
keyVaultOptions: {
208+
secretResolver: resolver,
209+
parallelSecretResolutionEnabled: true
210+
}
211+
});
212+
213+
expect(resolver.callCount).eq(1);
214+
for (const key of ["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"]) {
215+
expect(settings.get(key)).eq("ResolvedSecretValue");
216+
}
217+
});
218+
219+
it("should fetch different versions of the same secret independently", async () => {
220+
const versionedUri = "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName/741a0fc52610449baffd6e1c55b9d459";
221+
const kvs = [
222+
createMockedKeyVaultReference("TestKey", sameSecretUri),
223+
createMockedKeyVaultReference("TestKeyVersioned", versionedUri)
224+
];
225+
mockAppConfigurationClientListConfigurationSettings([kvs]);
226+
const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential());
227+
const stub = sinon.stub(client, "getSecret").callsFake(async (_name, options) => {
228+
await sleepInMs(100);
229+
return { value: options?.version ? "VersionedValue" : "LatestValue" } as KeyVaultSecret;
230+
});
231+
232+
const settings = await load(createMockedConnectionString(), {
233+
keyVaultOptions: {
234+
secretClients: [client],
235+
parallelSecretResolutionEnabled: true
236+
}
237+
});
238+
239+
expect(stub.callCount).eq(2);
240+
expect(settings.get("TestKey")).eq("LatestValue");
241+
expect(settings.get("TestKeyVersioned")).eq("VersionedValue");
242+
});
243+
244+
it("should not cache failures and retry on a subsequent attempt", async () => {
245+
mockDuplicateReferences();
246+
const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential());
247+
const stub = sinon.stub(client, "getSecret");
248+
// The first (deduplicated) request rejects; the retry attempt succeeds.
249+
// If the failure were cached, the retry would never succeed.
250+
stub.onCall(0).callsFake(async () => {
251+
await sleepInMs(100);
252+
throw new Error("Key Vault unavailable");
253+
});
254+
stub.callsFake(async () => {
255+
return { value: "SecretValue" } as KeyVaultSecret;
256+
});
257+
258+
const settings = await load(createMockedConnectionString(), {
259+
keyVaultOptions: {
260+
secretClients: [client],
261+
parallelSecretResolutionEnabled: true
262+
}
263+
});
264+
265+
// First round: 5 concurrent references deduped to a single failing request.
266+
// Second round (after load retry): a single succeeding request.
267+
expect(stub.callCount).eq(2);
268+
for (const key of ["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"]) {
269+
expect(settings.get(key)).eq("SecretValue");
270+
}
271+
});
272+
273+
it("should re-fetch once per unique secret on each refresh round", async () => {
274+
mockDuplicateReferences();
275+
const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential());
276+
let callCount = 0;
277+
sinon.stub(client, "getSecret").callsFake(async () => {
278+
callCount++;
279+
await sleepInMs(100);
280+
return { value: `SecretValue-${callCount}` } as KeyVaultSecret;
281+
});
282+
283+
const settings = await load(createMockedConnectionString(), {
284+
keyVaultOptions: {
285+
secretClients: [client],
286+
secretRefreshIntervalInMs: 60_000,
287+
parallelSecretResolutionEnabled: true
288+
}
289+
});
290+
// Initial load resolves duplicates with a single request.
291+
expect(callCount).eq(1);
292+
expect(settings.get("TestKey1")).eq("SecretValue-1");
293+
294+
// After the secret refresh interval elapses, the refresh round re-fetches once.
295+
await sleepInMs(60_000 + 100);
296+
await settings.refresh();
297+
expect(callCount).eq(2);
298+
expect(settings.get("TestKey1")).eq("SecretValue-2");
299+
});
300+
});
301+
145302
describe("key vault secret refresh", function () {
146303

147304
beforeEach(() => {

0 commit comments

Comments
 (0)