diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index e247b31..067c901 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -569,6 +569,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } if (this.#secretReferences.length > 0) { + for (const adapter of this.#adapters) { + await adapter.preload?.(this.#secretReferences); // dedup and warm the secret cache + } await this.#resolveSecretReferences(this.#secretReferences, (key, value) => { keyValues.push([key, value]); }); @@ -715,6 +718,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return Promise.resolve(false); } + for (const adapter of this.#adapters) { + await adapter.preload?.(this.#secretReferences); // dedup and warm the secret cache + } await this.#resolveSecretReferences(this.#secretReferences, (key, value) => { this.#configMap.set(key, value); }); diff --git a/src/keyValueAdapter.ts b/src/keyValueAdapter.ts index 222461d..865684f 100644 --- a/src/keyValueAdapter.ts +++ b/src/keyValueAdapter.ts @@ -14,6 +14,12 @@ export interface IKeyValueAdapter { */ processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]>; + /** + * Optionally batch-resolves settings ahead of processKeyValue, e.g. to deduplicate and warm 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. */ diff --git a/src/keyvault/keyVaultKeyValueAdapter.ts b/src/keyvault/keyVaultKeyValueAdapter.ts index 2e8eb4d..b866e15 100644 --- a/src/keyvault/keyVaultKeyValueAdapter.ts +++ b/src/keyvault/keyVaultKeyValueAdapter.ts @@ -49,6 +49,32 @@ export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { } } + /** + * Deduplicates secret references by their normalized secret identifier (sourceId) and preloads each + * unique secret exactly once, warming the cache so that processKeyValue only reads from it. + * Best-effort: unparseable references are skipped and re-surfaced by processKeyValue with full context. + */ + async preload(settings: ConfigurationSetting[]): Promise { + if (!this.#keyVaultOptions) { + return; // nothing to do; processKeyValue will throw the proper ArgumentError + } + const uniqueSecretIdentifiers = new Map(); + for (const setting of settings) { + if (!this.canProcess(setting)) { + continue; + } + try { + const secretIdentifier = parseKeyVaultSecretIdentifier( + parseSecretReference(setting).value.secretId + ); + uniqueSecretIdentifiers.set(secretIdentifier.sourceId, secretIdentifier); // dedup by sourceId + } catch { + // Skip invalid references; processKeyValue re-parses and raises KeyVaultReferenceError with context. + } + } + await this.#keyVaultSecretProvider.preloadSecrets([...uniqueSecretIdentifiers.values()]); + } + async onChangeDetected(): Promise { this.#keyVaultSecretProvider.clearCache(); return; diff --git a/src/keyvault/keyVaultSecretProvider.ts b/src/keyvault/keyVaultSecretProvider.ts index fa3d825..aee75e3 100644 --- a/src/keyvault/keyVaultSecretProvider.ts +++ b/src/keyvault/keyVaultSecretProvider.ts @@ -33,16 +33,53 @@ export class AzureKeyVaultSecretProvider { } } + /** + * Fetches the given unique secrets ahead of resolution to warm the cache. Honors the secret refresh + * timer and the parallel resolution option. This is best-effort: per-secret failures are swallowed so + * that the error is surfaced with full context later by getSecretValue during resolution. + */ + async preloadSecrets(secretIdentifiers: KeyVaultSecretIdentifier[]): Promise { + const loadSecret = async (secretIdentifier: KeyVaultSecretIdentifier) => { + try { + await this.#loadSecretValue(secretIdentifier); + } catch { + // Leave uncached; getSecretValue re-fetches and surfaces the error during resolution. + } + }; + + if (this.#keyVaultOptions?.parallelSecretResolutionEnabled) { + await Promise.all(secretIdentifiers.map(loadSecret)); + } else { + for (const secretIdentifier of secretIdentifiers) { + await loadSecret(secretIdentifier); + } + } + } + + /** + * Fetches a secret value into the cache if it is not cached yet, or if the secret refresh interval has + * expired. This is the only place the refresh timer gates a fetch. + */ + async #loadSecretValue(secretIdentifier: KeyVaultSecretIdentifier): Promise { + const identifierKey = secretIdentifier.sourceId; + const shouldRefresh = this.#secretRefreshTimer?.canRefresh() ?? false; + if (this.#cachedSecretValues.has(identifierKey) && !shouldRefresh) { + return; // already cached and still fresh + } + this.#cachedSecretValues.set(identifierKey, await this.#getSecretValueFromKeyVault(secretIdentifier)); + } + async getSecretValue(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); + // Return the cached value if available. Freshness is handled by preloadSecrets, which warms the + // cache before resolution. + if (this.#cachedSecretValues.has(identifierKey)) { + return this.#cachedSecretValues.get(identifierKey); } - // Fallback to fetching the secret value from Key Vault. + // Fallback for secrets that preload skipped or failed to fetch. Failures are not cached, so a + // subsequent call will retry. const secretValue = await this.#getSecretValueFromKeyVault(secretIdentifier); this.#cachedSecretValues.set(identifierKey, secretValue); return secretValue; diff --git a/test/keyvault.test.ts b/test/keyvault.test.ts index feeb294..80e8733 100644 --- a/test/keyvault.test.ts +++ b/test/keyvault.test.ts @@ -142,6 +142,219 @@ 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 sentinelEtag = "sentinel-etag"; + const kvs = [ + ...["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"].map((key) => createMockedKeyVaultReference(key, sameSecretUri)), + createMockedKeyValue({ key: "sentinel", value: "v1", etag: sentinelEtag }) + ]; + mockAppConfigurationClientListConfigurationSettings([kvs]); + mockAppConfigurationClientGetConfigurationSetting(kvs); + + 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(), { + 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); + + // Wait past the min secret refresh interval so the key-value change reload re-fetches secrets. + await sleepInMs(60_000 + 100); + + // Trigger a key-value change reload by changing the sentinel. + const updatedKvs = [ + ...["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"].map((key) => createMockedKeyVaultReference(key, sameSecretUri)), + createMockedKeyValue({ key: "sentinel", value: "v2", etag: "sentinel-etag-2" }) + ]; + restoreMocks(); + mockAppConfigurationClientListConfigurationSettings([updatedKvs]); + mockAppConfigurationClientGetConfigurationSetting(updatedKvs); + let reloadCallCount = 0; + sinon.stub(client, "getSecret").callsFake(async () => { + reloadCallCount++; + await sleepInMs(100); + return { value: "SecretValue-reloaded" } as KeyVaultSecret; + }); + + await settings.refresh(); + + // The key-value change reload clears the cache and re-fetches, but only once for the unique secret. + expect(reloadCallCount).eq(1); + for (const key of ["TestKey1", "TestKey2", "TestKey3", "TestKey4", "TestKey5"]) { + expect(settings.get(key)).eq("SecretValue-reloaded"); + } + }); + + 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(() => {