From f62418107c30fb5089096e7bb986e719f5e967cd Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Tue, 10 Feb 2026 10:42:01 +0800 Subject: [PATCH] optimization for afd watch scenarios --- package-lock.json | 18 +++-------- package.json | 2 +- src/appConfigurationImpl.ts | 3 +- src/requestTracing/utils.ts | 10 +++++++ test/afd.test.ts | 53 +++++++++++++++++++------------- test/utils/testHelper.ts | 60 +++++++++++++++++++++++++++++++++++++ 6 files changed, 110 insertions(+), 36 deletions(-) diff --git a/package-lock.json b/package-lock.json index f6ac125..dfe66c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.4.1-preview", "license": "MIT", "dependencies": { - "@azure/app-configuration": "^1.10.0", + "@azure/app-configuration": "^1.11.0", "@azure/core-rest-pipeline": "^1.6.0", "@azure/identity": "^4.2.1", "@azure/keyvault-secrets": "^4.7.0", @@ -74,9 +74,9 @@ } }, "node_modules/@azure/app-configuration": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.10.0.tgz", - "integrity": "sha512-WA5Q70uGQfn6KAgh5ilYuLT8kwkYg5gr6qXH3HGx7OioNDkM6HRPHDWyuAk/G9+20Y0nt7jKTJEGF7NrMIYb+A==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.11.0.tgz", + "integrity": "sha512-ehfTNvVyr4lFKz1Nfynubqg/kEWMFMjCQs8lADDa+U1HG96QIoelMYFbfWIixw4KhOqoCAIOWEp4bAXIZ0/V/w==", "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.0.0", @@ -1613,7 +1613,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -1703,7 +1702,6 @@ "integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1768,7 +1766,6 @@ "integrity": "sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.41.0", "@typescript-eslint/types": "8.41.0", @@ -1988,7 +1985,6 @@ "integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@testing-library/dom": "^10.4.0", "@testing-library/user-event": "^14.6.1", @@ -2221,7 +2217,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2418,7 +2413,6 @@ "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.3", @@ -2900,7 +2894,6 @@ "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -4371,7 +4364,6 @@ "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright-core": "1.56.1" }, @@ -4723,7 +4715,6 @@ "integrity": "sha512-/Zl4D8zPifNmyGzJS+3kVoyXeDeT/GrsJM94sACNg9RtUE0hrHa1bNPtRSrfHTMH5HjRzce6K7rlTh3Khiw+pw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -5275,7 +5266,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index a13976b..673bc3f 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "playwright": "^1.55.0" }, "dependencies": { - "@azure/app-configuration": "^1.10.0", + "@azure/app-configuration": "^1.11.0", "@azure/core-rest-pipeline": "^1.6.0", "@azure/identity": "^4.2.1", "@azure/keyvault-secrets": "^4.7.0", diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index 1aea1ed..4b642e6 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -58,6 +58,7 @@ import { AzureKeyVaultKeyValueAdapter } from "./keyvault/keyVaultKeyValueAdapter import { RefreshTimer } from "./refresh/refreshTimer.js"; import { RequestTracingOptions, + checkConfigurationSettingsWithTrace, getConfigurationSettingWithTrace, listConfigurationSettingsWithTrace, getSnapshotWithTrace, @@ -766,7 +767,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { listOptions.pageEtags = pageWatchers.map(w => w.etag ?? "") ; } - const pageIterator = listConfigurationSettingsWithTrace( + const pageIterator = checkConfigurationSettingsWithTrace( this.#requestTraceOptions, client, listOptions diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index 7428081..2e54e33 100644 --- a/src/requestTracing/utils.ts +++ b/src/requestTracing/utils.ts @@ -7,6 +7,7 @@ import { ConfigurationSettingId, GetConfigurationSettingOptions, ListConfigurationSettingsOptions, + CheckConfigurationSettingsOptions, GetSnapshotOptions, ListConfigurationSettingsForSnapshotOptions } from "@azure/app-configuration"; @@ -69,6 +70,15 @@ export function listConfigurationSettingsWithTrace( return client.listConfigurationSettings(actualListOptions); } +export function checkConfigurationSettingsWithTrace( + requestTracingOptions: RequestTracingOptions, + client: AppConfigurationClient, + checkOptions: CheckConfigurationSettingsOptions +) { + const actualCheckOptions = applyRequestTracing(requestTracingOptions, checkOptions); + return client.checkConfigurationSettings(actualCheckOptions); +} + export function getConfigurationSettingWithTrace( requestTracingOptions: RequestTracingOptions, client: AppConfigurationClient, diff --git a/test/afd.test.ts b/test/afd.test.ts index e807a38..f575bde 100644 --- a/test/afd.test.ts +++ b/test/afd.test.ts @@ -134,30 +134,35 @@ describe("loadFromAzureFrontDoor", function() { const kv2_updated = createMockedKeyValue({ key: "app.key2", value: "value2-updated" }); const kv3 = createMockedKeyValue({ key: "app.key3", value: "value3" }); - const stub = sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings"); + const listStub = sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings"); + const checkStub = sinon.stub(AppConfigurationClient.prototype, "checkConfigurationSettings" as any); - stub.onCall(0).returns(getCachedIterator([ + // Initial load + listStub.onCall(0).returns(getCachedIterator([ { items: [kv1, kv2], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } ])); - stub.onCall(1).returns(getCachedIterator([ + // 1st refresh: check (HEAD) detects change, then reload (GET) + checkStub.onCall(0).returns(getCachedIterator([ { items: [kv1, kv2_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:01Z") } } ])); - stub.onCall(2).returns(getCachedIterator([ + listStub.onCall(1).returns(getCachedIterator([ { items: [kv1, kv2_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:01Z") } } ])); - stub.onCall(3).returns(getCachedIterator([ + // 2nd refresh: check (HEAD) detects change, then reload (GET) + checkStub.onCall(1).returns(getCachedIterator([ { items: [kv1], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:03Z") } } ])); - stub.onCall(4).returns(getCachedIterator([ + listStub.onCall(2).returns(getCachedIterator([ { items: [kv1], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:03Z") } } ])); - stub.onCall(5).returns(getCachedIterator([ + // 3rd refresh: check (HEAD) detects change, then reload (GET) + checkStub.onCall(2).returns(getCachedIterator([ { items: [kv1, kv3], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:05Z") } } ])); - stub.onCall(6).returns(getCachedIterator([ + listStub.onCall(3).returns(getCachedIterator([ { items: [kv1, kv3], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:05Z") } } ])); @@ -192,19 +197,22 @@ describe("loadFromAzureFrontDoor", function() { const ff = createMockedFeatureFlag("Beta"); const ff_updated = createMockedFeatureFlag("Beta", { enabled: false }); - const stub = sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings"); + const listStub = sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings"); + const checkStub = sinon.stub(AppConfigurationClient.prototype, "checkConfigurationSettings" as any); - stub.onCall(0).returns(getCachedIterator([ + // Initial load: onCall(0) = default KV selector, onCall(1) = feature flags + listStub.onCall(0).returns(getCachedIterator([ { items: [ff], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } ])); - stub.onCall(1).returns(getCachedIterator([ + listStub.onCall(1).returns(getCachedIterator([ { items: [ff], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } ])); - stub.onCall(2).returns(getCachedIterator([ + // 1st refresh: check (HEAD) detects change, then reload (GET) + checkStub.onCall(0).returns(getCachedIterator([ { items: [ff_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:03Z") } } ])); - stub.onCall(3).returns(getCachedIterator([ + listStub.onCall(2).returns(getCachedIterator([ { items: [ff_updated], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:03Z") } } ])); @@ -235,18 +243,23 @@ describe("loadFromAzureFrontDoor", function() { const kv1_stale = createMockedKeyValue({ key: "app.key1", value: "stale-value" }); const kv1_new = createMockedKeyValue({ key: "app.key1", value: "new-value" }); - const stub = sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings"); - stub.onCall(0).returns(getCachedIterator([ + const listStub = sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings"); + const checkStub = sinon.stub(AppConfigurationClient.prototype, "checkConfigurationSettings" as any); + + // Initial load + listStub.onCall(0).returns(getCachedIterator([ { items: [kv1], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:01Z") } } ])); - stub.onCall(1).returns(getCachedIterator([ - { items: [kv1_stale], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } // stale response, should not trigger refresh + // 1st refresh: check (HEAD) returns stale response, should not trigger refresh + checkStub.onCall(0).returns(getCachedIterator([ + { items: [kv1_stale], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:00Z") } } ])); - stub.onCall(2).returns(getCachedIterator([ - { items: [kv1_new], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:02Z") } } // new response, should trigger refresh + // 2nd refresh: check (HEAD) detects change, then reload (GET) + checkStub.onCall(1).returns(getCachedIterator([ + { items: [kv1_new], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:02Z") } } ])); - stub.onCall(3).returns(getCachedIterator([ + listStub.onCall(1).returns(getCachedIterator([ { items: [kv1_new], response: { status: 200, headers: createTimestampHeaders("2025-09-07T00:00:02Z") } } ])); diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index 492b77e..745f448 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -150,6 +150,48 @@ function getCachedIterator(pages: Array<{ return iterator as any; } +function getMockedHeadIterator(pages: ConfigurationSetting[][], listOptions: any) { + const mockIterator: AsyncIterableIterator & { byPage(): AsyncIterableIterator } = { + [Symbol.asyncIterator](): AsyncIterableIterator { + return this; + }, + next() { + return Promise.resolve({ done: true, value: undefined }); + }, + byPage(): AsyncIterableIterator { + let remainingPages; + const pageEtags = listOptions?.pageEtags ? [...listOptions.pageEtags] : undefined; + return { + [Symbol.asyncIterator](): AsyncIterableIterator { + remainingPages = [...pages]; + return this; + }, + async next() { + const pageItems = remainingPages.shift(); + const pageEtag = pageEtags?.shift(); + if (pageItems === undefined) { + return { done: true, value: undefined }; + } else { + const items = _filterKVs(pageItems ?? [], listOptions); + const etag = await _sha256(JSON.stringify(items)); + const statusCode = pageEtag === etag ? 304 : 200; + return { + done: false, + value: { + items: [], // HEAD request returns no items + etag, + _response: { status: statusCode } + } + }; + } + } + }; + } + }; + + return mockIterator as any; +} + /** * Mocks the listConfigurationSettings method of AppConfigurationClient to return the provided pages of ConfigurationSetting. * E.g. @@ -167,6 +209,14 @@ function mockAppConfigurationClientListConfigurationSettings(pages: Configuratio const kvs = _filterKVs(pages.flat(), listOptions); return getMockedIterator(pages, kvs, listOptions); }); + + sinon.stub(AppConfigurationClient.prototype, "checkConfigurationSettings").callsFake((listOptions) => { + if (customCallback) { + customCallback(listOptions); + } + + return getMockedHeadIterator(pages, listOptions); + }); } function mockAppConfigurationClientLoadBalanceMode(pages: ConfigurationSetting[][], clientWrapper: ConfigurationClientWrapper, countObject: { count: number }) { @@ -175,6 +225,10 @@ function mockAppConfigurationClientLoadBalanceMode(pages: ConfigurationSetting[] const kvs = _filterKVs(pages.flat(), listOptions); return getMockedIterator(pages, kvs, listOptions); }); + sinon.stub(clientWrapper.client, "checkConfigurationSettings").callsFake((listOptions) => { + countObject.count += 1; + return getMockedHeadIterator(pages, listOptions); + }); } function mockConfigurationManagerGetClients(fakeClientWrappers: ConfigurationClientWrapper[], isFailoverable: boolean, ...pages: ConfigurationSetting[][]) { @@ -189,6 +243,9 @@ function mockConfigurationManagerGetClients(fakeClientWrappers: ConfigurationCli sinon.stub(fakeStaticClientWrapper.client, "listConfigurationSettings").callsFake(() => { throw new RestError("Internal Server Error", { statusCode: 500 }); }); + sinon.stub(fakeStaticClientWrapper.client, "checkConfigurationSettings").callsFake(() => { + throw new RestError("Internal Server Error", { statusCode: 500 }); + }); clients.push(fakeStaticClientWrapper); if (!isFailoverable) { @@ -202,6 +259,9 @@ function mockConfigurationManagerGetClients(fakeClientWrappers: ConfigurationCli const kvs = _filterKVs(pages.flat(), listOptions); return getMockedIterator(pages, kvs, listOptions); }); + sinon.stub(fakeDynamicClientWrapper.client, "checkConfigurationSettings").callsFake((listOptions) => { + return getMockedHeadIterator(pages, listOptions); + }); return clients; }); }