Skip to content

Commit e4ca372

Browse files
committed
load balance support
1 parent 7db665f commit e4ca372

File tree

8 files changed

+167
-11
lines changed

8 files changed

+167
-11
lines changed

src/AzureAppConfigurationImpl.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,12 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
7575
#featureFlagRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS;
7676
#featureFlagRefreshTimer: RefreshTimer;
7777

78-
// selectors
78+
// Selectors
7979
#featureFlagSelectors: PagedSettingSelector[] = [];
8080

81+
// Load balancing
82+
#lastSuccessfulEndpoint: string = "";
83+
8184
constructor(
8285
clientManager: ConfigurationClientManager,
8386
options: AzureAppConfigurationOptions | undefined,
@@ -202,14 +205,29 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
202205
}
203206

204207
async #executeWithFailoverPolicy(funcToExecute: (client: AppConfigurationClient) => Promise<any>): Promise<any> {
205-
const clientWrappers = await this.#clientManager.getClients();
208+
let clientWrappers = await this.#clientManager.getClients();
209+
if (this.#options?.loadBalancingEnabled && this.#lastSuccessfulEndpoint !== "" && clientWrappers.length > 1) {
210+
let nextClientIndex = 0;
211+
// Iterate through clients to find the index of the client with the last successful endpoint
212+
for (const clientWrapper of clientWrappers) {
213+
nextClientIndex++;
214+
if (clientWrapper.endpoint === this.#lastSuccessfulEndpoint) {
215+
break;
216+
}
217+
}
218+
// If we found the last successful client, rotate the list so that the next client is at the beginning
219+
if (nextClientIndex < clientWrappers.length) {
220+
clientWrappers = [...clientWrappers.slice(nextClientIndex), ...clientWrappers.slice(0, nextClientIndex)];
221+
}
222+
}
206223

207224
let successful: boolean;
208225
for (const clientWrapper of clientWrappers) {
209226
successful = false;
210227
try {
211228
const result = await funcToExecute(clientWrapper.client);
212229
this.#isFailoverRequest = false;
230+
this.#lastSuccessfulEndpoint = clientWrapper.endpoint;
213231
successful = true;
214232
clientWrapper.updateBackoffStatus(successful);
215233
return result;

src/AzureAppConfigurationOptions.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,12 @@ export interface AzureAppConfigurationOptions {
5555
* If not specified, the default value is true.
5656
*/
5757
replicaDiscoveryEnabled?: boolean;
58+
59+
/**
60+
* Specifies whether to enable load balance or not.
61+
*
62+
* @remarks
63+
* If not specified, the default value is false.
64+
*/
65+
loadBalancingEnabled?: boolean;
5866
}

src/ConfigurationClientWrapper.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export class ConfigurationClientWrapper {
1212
endpoint: string;
1313
client: AppConfigurationClient;
1414
backoffEndTime: number = 0; // Timestamp
15-
#failedAttempts: number = 0;
15+
failedAttempts: number = 0;
1616

1717
constructor(endpoint: string, client: AppConfigurationClient) {
1818
this.endpoint = endpoint;
@@ -21,11 +21,17 @@ export class ConfigurationClientWrapper {
2121

2222
updateBackoffStatus(successfull: boolean) {
2323
if (successfull) {
24-
this.#failedAttempts = 0;
24+
if (this.failedAttempts > 0) {
25+
this.failedAttempts = 0;
26+
}
27+
this.failedAttempts -= 1;
2528
this.backoffEndTime = Date.now();
2629
} else {
27-
this.#failedAttempts += 1;
28-
this.backoffEndTime = Date.now() + calculateBackoffDuration(this.#failedAttempts);
30+
if (this.failedAttempts < 0) {
31+
this.failedAttempts = 0;
32+
}
33+
this.failedAttempts += 1;
34+
this.backoffEndTime = Date.now() + calculateBackoffDuration(this.failedAttempts);
2935
}
3036
}
3137
}

src/requestTracing/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ export enum RequestType {
4444
WATCH = "Watch"
4545
}
4646

47+
export const FEATURES_KEY = "Features";
48+
4749
// Tag names
4850
export const FAILOVER_REQUEST_TAG = "Failover";
4951
export const KEY_VAULT_CONFIGURED_TAG = "UsesKeyVault";
52+
export const LOAD_BALANCE_CONFIGURED_TAG = "LB";

src/requestTracing/utils.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import {
2020
RequestType,
2121
SERVICE_FABRIC_ENV_VAR,
2222
CORRELATION_CONTEXT_HEADER_NAME,
23-
FAILOVER_REQUEST_TAG
23+
FAILOVER_REQUEST_TAG,
24+
FEATURES_KEY,
25+
LOAD_BALANCE_CONFIGURED_TAG
2426
} from "./constants";
2527

2628
// Utils
@@ -84,6 +86,9 @@ export function createCorrelationContextHeader(options: AzureAppConfigurationOpt
8486
keyValues.set(REQUEST_TYPE_KEY, isInitialLoadCompleted ? RequestType.WATCH : RequestType.STARTUP);
8587
keyValues.set(HOST_TYPE_KEY, getHostType());
8688
keyValues.set(ENV_KEY, isDevEnvironment() ? DEV_ENV_VAL : undefined);
89+
if (options?.loadBalancingEnabled) {
90+
keyValues.set(FEATURES_KEY, LOAD_BALANCE_CONFIGURED_TAG);
91+
}
8792

8893
const tags: string[] = [];
8994
if (options?.keyVaultOptions) {

test/failover.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ describe("failover", function () {
3535

3636
it("should failover to replica and load key values from config store", async () => {
3737
const isFailoverable = true;
38-
mockConfigurationManagerGetClients(isFailoverable, mockedKVs);
38+
mockConfigurationManagerGetClients([], isFailoverable, mockedKVs);
3939

4040
const connectionString = createMockedConnectionString();
4141
// replicaDiscoveryEnabled is default to true
@@ -47,7 +47,7 @@ describe("failover", function () {
4747

4848
it("should failover to replica and load feature flags from config store", async () => {
4949
const isFailoverable = true;
50-
mockConfigurationManagerGetClients(isFailoverable, mockedFeatureFlags);
50+
mockConfigurationManagerGetClients([], isFailoverable, mockedFeatureFlags);
5151

5252
const connectionString = createMockedConnectionString();
5353
// replicaDiscoveryEnabled is default to true
@@ -66,7 +66,7 @@ describe("failover", function () {
6666

6767
it("should throw error when all clients failed", async () => {
6868
const isFailoverable = false;
69-
mockConfigurationManagerGetClients(isFailoverable);
69+
mockConfigurationManagerGetClients([], isFailoverable);
7070

7171
const connectionString = createMockedConnectionString();
7272
return expect(load(connectionString)).eventually.rejectedWith("All clients failed to get configuration settings.");

test/loadBalance.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import * as chai from "chai";
5+
import * as chaiAsPromised from "chai-as-promised";
6+
chai.use(chaiAsPromised);
7+
const expect = chai.expect;
8+
import { load } from "./exportedApi.js";
9+
import { mockAppConfigurationClientListConfigurationSettings, restoreMocks, createMockedConnectionString, sleepInMs, createMockedFeatureFlag, createMockedEndpoint, mockConfigurationManagerGetClients } from "./utils/testHelper.js";
10+
import { AppConfigurationClient } from "@azure/app-configuration";
11+
import { ConfigurationClientWrapper } from "../src/ConfigurationClientWrapper.js";
12+
13+
describe("load balance", function () {
14+
this.timeout(10000);
15+
16+
beforeEach(() => {
17+
});
18+
19+
afterEach(() => {
20+
restoreMocks();
21+
});
22+
23+
it("should load balance the request when loadBalancingEnabled", async () => {
24+
// mock multiple pages of feature flags
25+
const page1 = [
26+
createMockedFeatureFlag("Alpha_1", { enabled: true }),
27+
createMockedFeatureFlag("Alpha_2", { enabled: true }),
28+
];
29+
const page2 = [
30+
createMockedFeatureFlag("Beta_1", { enabled: true }),
31+
createMockedFeatureFlag("Beta_2", { enabled: true }),
32+
];
33+
const fakeEndpoint_1 = createMockedEndpoint("fake_1");
34+
const fakeEndpoint_2 = createMockedEndpoint("fake_2");
35+
const fakeClientWrapper_1 = new ConfigurationClientWrapper(fakeEndpoint_1, new AppConfigurationClient(createMockedConnectionString(fakeEndpoint_1)));
36+
const fakeClientWrapper_2 = new ConfigurationClientWrapper(fakeEndpoint_2, new AppConfigurationClient(createMockedConnectionString(fakeEndpoint_2)));
37+
const mockedClientWrappers = [fakeClientWrapper_1, fakeClientWrapper_2];
38+
mockConfigurationManagerGetClients(mockedClientWrappers, false);
39+
mockAppConfigurationClientListConfigurationSettings(page1, page2);
40+
41+
const connectionString = createMockedConnectionString();
42+
const settings = await load(connectionString, {
43+
loadBalancingEnabled: true,
44+
featureFlagOptions: {
45+
enabled: true,
46+
selectors: [{
47+
keyFilter: "*"
48+
}],
49+
refresh: {
50+
enabled: true,
51+
refreshIntervalInMs: 2000 // 2 seconds for quick test.
52+
}
53+
}
54+
});
55+
// one request for key values, one request for feature flags
56+
expect(fakeClientWrapper_1.failedAttempts).eq(-1);
57+
expect(fakeClientWrapper_2.failedAttempts).eq(-1);
58+
59+
await sleepInMs(2 * 1000 + 1);
60+
await settings.refresh();
61+
// refresh request for feature flags
62+
expect(fakeClientWrapper_1.failedAttempts).eq(-2);
63+
expect(fakeClientWrapper_2.failedAttempts).eq(-1);
64+
65+
await sleepInMs(2 * 1000 + 1);
66+
await settings.refresh();
67+
expect(fakeClientWrapper_1.failedAttempts).eq(-2);
68+
expect(fakeClientWrapper_2.failedAttempts).eq(-2);
69+
});
70+
71+
it("should load balance the request when loadBalancingEnabled", async () => {
72+
// mock multiple pages of feature flags
73+
const page1 = [
74+
createMockedFeatureFlag("Alpha_1", { enabled: true }),
75+
createMockedFeatureFlag("Alpha_2", { enabled: true }),
76+
];
77+
const page2 = [
78+
createMockedFeatureFlag("Beta_1", { enabled: true }),
79+
createMockedFeatureFlag("Beta_2", { enabled: true }),
80+
];
81+
const fakeEndpoint_1 = createMockedEndpoint("fake_1");
82+
const fakeEndpoint_2 = createMockedEndpoint("fake_2");
83+
const fakeClientWrapper_1 = new ConfigurationClientWrapper(fakeEndpoint_1, new AppConfigurationClient(createMockedConnectionString(fakeEndpoint_1)));
84+
const fakeClientWrapper_2 = new ConfigurationClientWrapper(fakeEndpoint_2, new AppConfigurationClient(createMockedConnectionString(fakeEndpoint_2)));
85+
const mockedClientWrappers = [fakeClientWrapper_1, fakeClientWrapper_2];
86+
mockConfigurationManagerGetClients(mockedClientWrappers, false);
87+
mockAppConfigurationClientListConfigurationSettings(page1, page2);
88+
89+
const connectionString = createMockedConnectionString();
90+
// loadBalancingEnabled is default to false
91+
const settings = await load(connectionString, {
92+
featureFlagOptions: {
93+
enabled: true,
94+
selectors: [{
95+
keyFilter: "*"
96+
}],
97+
refresh: {
98+
enabled: true,
99+
refreshIntervalInMs: 2000 // 2 seconds for quick test.
100+
}
101+
}
102+
});
103+
// one request for key values, one request for feature flags
104+
expect(fakeClientWrapper_1.failedAttempts).eq(-2);
105+
expect(fakeClientWrapper_2.failedAttempts).eq(0);
106+
107+
await sleepInMs(2 * 1000 + 1);
108+
await settings.refresh();
109+
// refresh request for feature flags
110+
expect(fakeClientWrapper_1.failedAttempts).eq(-3);
111+
expect(fakeClientWrapper_2.failedAttempts).eq(0);
112+
});
113+
});

test/utils/testHelper.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,12 @@ function mockAppConfigurationClientListConfigurationSettings(...pages: Configura
100100
});
101101
}
102102

103-
function mockConfigurationManagerGetClients(isFailoverable: boolean, ...pages: ConfigurationSetting[][]) {
103+
function mockConfigurationManagerGetClients(fakeClientWrappers: ConfigurationClientWrapper[], isFailoverable: boolean, ...pages: ConfigurationSetting[][]) {
104104
// Stub the getClients method on the class prototype
105105
sinon.stub(ConfigurationClientManager.prototype, "getClients").callsFake(async () => {
106+
if (fakeClientWrappers?.length > 0) {
107+
return fakeClientWrappers;
108+
}
106109
const clients: ConfigurationClientWrapper[] = [];
107110
const fakeEndpoint = createMockedEndpoint("fake");
108111
const fakeStaticClientWrapper = new ConfigurationClientWrapper(fakeEndpoint, new AppConfigurationClient(createMockedConnectionString(fakeEndpoint)));

0 commit comments

Comments
 (0)