Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/appConfigurationImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

@zhiyuanliang-ms zhiyuanliang-ms Jul 14, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should call preload hook in loadSelectedKeyValue like this:

for (const adapter of this.#adapters) {
   if (adapter.preload) {
	await adapter.preload(settings.filter(s => adapter.canProcess(s)));
   }
}

}
await this.#resolveSecretReferences(this.#secretReferences, (key, value) => {
keyValues.push([key, value]);
});
Expand Down Expand Up @@ -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);
});
Expand Down
5 changes: 5 additions & 0 deletions src/keyValueAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ 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<void>;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method should not be optional. We should make it and its comment consistent with OnChangeDetected.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the comment we should explain the parameter: whether it should be just a bunch of configuration settings or a set of certain type of configuration settings that the adapter can process

@zhiyuanliang-ms zhiyuanliang-ms Jul 14, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually prefer to let preload only take settings that the adapter can process. This will match the orchestrator(appConfigurationImpl) + kv adapter design.

The orchestrator should call preload like this:

for (const adapter of this.#adapters) {
    await adapter.preload(settings.filter(s => adapter.canProcess(s)));
}

The above code has very clear intention.
But the concern here is that it will increase the cost to MxN (M is adapater count, N is settings count)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With that concern, I think maybe we should make preload and onChangeDetected optional method under the IKeyValueAdapter to leverage the JS/TS language characteristics for better performance.


/**
* This method is called when a change is detected in the configuration setting.
*/
Expand Down
21 changes: 21 additions & 0 deletions src/keyvault/keyVaultKeyValueAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,27 @@ export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter {
}
}

async preload(settings: ConfigurationSetting[]): Promise<void> {
if (!this.#keyVaultOptions) {
return; // nothing to do; processKeyValue will throw the proper ArgumentError

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return; // nothing to do; processKeyValue will throw the proper ArgumentError
return; // no-op when keyVaultOptions is not configured

}
const uniqueSecretIdentifiers = new Map<string, KeyVaultSecretIdentifier>();
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<void> {
this.#keyVaultSecretProvider.clearCache();
return;
Expand Down
37 changes: 31 additions & 6 deletions src/keyvault/keyVaultSecretProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,41 @@ export class AzureKeyVaultSecretProvider {
}
}

async getSecretValue(secretIdentifier: KeyVaultSecretIdentifier): Promise<unknown> {
// Fetches the given unique secrets to warm the cache.
async preloadSecrets(secretIdentifiers: KeyVaultSecretIdentifier[]): Promise<void> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

preload is the concept from the abstraction of kv adapter. This business logic should be placed in adapter implementation.

I think secret provider should offer two methods: 1. loadSecretValue 2. getSecretValue

loadSecretValue has refresh timer gate, if timer is not due, serve secret value from cache, when there is no cache or timer is due, the fallback is to send request to key vault to get secret value
getSecretValue simply serve secret value from cache, when there is no cache, the fallback is loadSecretValue

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);
}
}
}

async #loadSecretValue(secretIdentifier: KeyVaultSecretIdentifier): Promise<void> {
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));
}

// 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);
async getSecretValue(secretIdentifier: KeyVaultSecretIdentifier): Promise<unknown> {
const identifierKey = secretIdentifier.sourceId;
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.
const secretValue = await this.#getSecretValueFromKeyVault(secretIdentifier);
this.#cachedSecretValues.set(identifierKey, secretValue);
return secretValue;
Expand Down
213 changes: 213 additions & 0 deletions test/keyvault.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment on lines +351 to +352
Comment on lines +350 to +352
expect(callCount).eq(2);
expect(settings.get("TestKey1")).eq("SecretValue-2");
});
});

describe("key vault secret refresh", function () {

beforeEach(() => {
Expand Down
Loading