Skip to content

Commit 3219366

Browse files
Support snapshot reference (#232)
* wip * support snapshot reference * add test * update lint rule * update
1 parent b060a65 commit 3219366

File tree

11 files changed

+201
-34
lines changed

11 files changed

+201
-34
lines changed

package-lock.json

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
"playwright": "^1.55.0"
6767
},
6868
"dependencies": {
69-
"@azure/app-configuration": "^1.9.0",
69+
"@azure/app-configuration": "^1.10.0",
7070
"@azure/core-rest-pipeline": "^1.6.0",
7171
"@azure/identity": "^4.2.1",
7272
"@azure/keyvault-secrets": "^4.7.0",

src/appConfigurationImpl.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import {
1111
featureFlagPrefix,
1212
isFeatureFlag,
1313
isSecretReference,
14+
isSnapshotReference,
15+
parseSnapshotReference,
16+
SnapshotReferenceValue,
1417
GetSnapshotOptions,
1518
ListConfigurationSettingsForSnapshotOptions,
1619
GetSnapshotResponse,
@@ -57,7 +60,7 @@ import { AIConfigurationTracingOptions } from "./requestTracing/aiConfigurationT
5760
import { KeyFilter, LabelFilter, SettingWatcher, SettingSelector, PagedSettingsWatcher, WatchedSetting } from "./types.js";
5861
import { ConfigurationClientManager } from "./configurationClientManager.js";
5962
import { getFixedBackoffDuration, getExponentialBackoffDuration } from "./common/backoffUtils.js";
60-
import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError } from "./common/errors.js";
63+
import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError, SnapshotReferenceError } from "./common/errors.js";
6164
import { ErrorMessages } from "./common/errorMessages.js";
6265

6366
const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds
@@ -82,6 +85,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
8285
#featureFlagTracing: FeatureFlagTracingOptions | undefined;
8386
#fmVersion: string | undefined;
8487
#aiConfigurationTracing: AIConfigurationTracingOptions | undefined;
88+
#useSnapshotReference: boolean = false;
8589

8690
// Refresh
8791
#refreshInProgress: boolean = false;
@@ -213,7 +217,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
213217
isFailoverRequest: this.#isFailoverRequest,
214218
featureFlagTracing: this.#featureFlagTracing,
215219
fmVersion: this.#fmVersion,
216-
aiConfigurationTracing: this.#aiConfigurationTracing
220+
aiConfigurationTracing: this.#aiConfigurationTracing,
221+
useSnapshotReference: this.#useSnapshotReference
217222
};
218223
}
219224

@@ -504,17 +509,29 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
504509
selector.pageWatchers = pageWatchers;
505510
settings = items;
506511
} else { // snapshot selector
507-
const snapshot = await this.#getSnapshot(selector.snapshotName);
508-
if (snapshot === undefined) {
509-
throw new InvalidOperationError(`Could not find snapshot with name ${selector.snapshotName}.`);
510-
}
511-
if (snapshot.compositionType != KnownSnapshotComposition.Key) {
512-
throw new InvalidOperationError(`Composition type for the selected snapshot with name ${selector.snapshotName} must be 'key'.`);
513-
}
514-
settings = await this.#listConfigurationSettingsForSnapshot(selector.snapshotName);
512+
settings = await this.#loadConfigurationSettingsFromSnapshot(selector.snapshotName);
515513
}
516514

517515
for (const setting of settings) {
516+
if (isSnapshotReference(setting) && !loadFeatureFlag) {
517+
this.#useSnapshotReference = true;
518+
519+
const snapshotRef: ConfigurationSetting<SnapshotReferenceValue> = parseSnapshotReference(setting);
520+
const snapshotName = snapshotRef.value.snapshotName;
521+
if (!snapshotName) {
522+
throw new SnapshotReferenceError(`Invalid format for Snapshot reference setting '${setting.key}'.`);
523+
}
524+
const settingsFromSnapshot = await this.#loadConfigurationSettingsFromSnapshot(snapshotName);
525+
526+
for (const snapshotSetting of settingsFromSnapshot) {
527+
if (!isFeatureFlag(snapshotSetting)) {
528+
// Feature flags inside snapshot are ignored. This is consistent the behavior that key value selectors ignore feature flags.
529+
loadedSettings.set(snapshotSetting.key, snapshotSetting);
530+
}
531+
}
532+
continue;
533+
}
534+
518535
if (loadFeatureFlag === isFeatureFlag(setting)) {
519536
loadedSettings.set(setting.key, setting);
520537
}
@@ -575,6 +592,18 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
575592
}
576593
}
577594

595+
async #loadConfigurationSettingsFromSnapshot(snapshotName: string): Promise<ConfigurationSetting[]> {
596+
const snapshot = await this.#getSnapshot(snapshotName);
597+
if (snapshot === undefined) {
598+
return []; // treat non-existing snapshot as empty
599+
}
600+
if (snapshot.compositionType != KnownSnapshotComposition.Key) {
601+
throw new InvalidOperationError(`Composition type for the selected snapshot with name ${snapshotName} must be 'key'.`);
602+
}
603+
const settings: ConfigurationSetting[] = await this.#listConfigurationSettingsForSnapshot(snapshotName);
604+
return settings;
605+
}
606+
578607
/**
579608
* Clears all existing key-values in the local configuration except feature flags.
580609
*/

src/common/errors.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ export class KeyVaultReferenceError extends Error {
3333
}
3434
}
3535

36+
export class SnapshotReferenceError extends Error {
37+
constructor(message: string, options?: ErrorOptions) {
38+
super(message, options);
39+
this.name = "SnapshotReferenceError";
40+
}
41+
}
42+
3643
export function isFailoverableError(error: any): boolean {
3744
if (!isRestError(error)) {
3845
return false;

src/requestTracing/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export const REPLICA_COUNT_KEY = "ReplicaCount";
5151
export const KEY_VAULT_CONFIGURED_TAG = "UsesKeyVault";
5252
export const KEY_VAULT_REFRESH_CONFIGURED_TAG = "RefreshesKeyVault";
5353
export const FAILOVER_REQUEST_TAG = "Failover";
54+
export const SNAPSHOT_REFERENCE_TAG = "SnapshotRef";
5455

5556
// Compact feature tags
5657
export const FEATURES_KEY = "Features";

src/requestTracing/utils.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ import {
4141
FM_VERSION_KEY,
4242
DELIMITER,
4343
AI_CONFIGURATION_TAG,
44-
AI_CHAT_COMPLETION_CONFIGURATION_TAG
44+
AI_CHAT_COMPLETION_CONFIGURATION_TAG,
45+
SNAPSHOT_REFERENCE_TAG
4546
} from "./constants.js";
4647

4748
export interface RequestTracingOptions {
@@ -53,6 +54,7 @@ export interface RequestTracingOptions {
5354
featureFlagTracing: FeatureFlagTracingOptions | undefined;
5455
fmVersion: string | undefined;
5556
aiConfigurationTracing: AIConfigurationTracingOptions | undefined;
57+
useSnapshotReference: boolean;
5658
}
5759

5860
// Utils
@@ -195,6 +197,9 @@ function createFeaturesString(requestTracingOptions: RequestTracingOptions): str
195197
if (requestTracingOptions.aiConfigurationTracing?.usesAIChatCompletionConfiguration) {
196198
tags.push(AI_CHAT_COMPLETION_CONFIGURATION_TAG);
197199
}
200+
if (requestTracingOptions.useSnapshotReference) {
201+
tags.push(SNAPSHOT_REFERENCE_TAG);
202+
}
198203
return tags.join(DELIMITER);
199204
}
200205

test/featureFlag.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -415,8 +415,14 @@ describe("feature flags", function () {
415415

416416
it("should load feature flags from snapshot", async () => {
417417
const snapshotName = "Test";
418-
mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key"});
419-
mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotName, [[createMockedFeatureFlag("TestFeature", { enabled: true })]]);
418+
const snapshotResponses = new Map([
419+
[snapshotName, { compositionType: "key" }]
420+
]);
421+
const snapshotKVs = new Map([
422+
[snapshotName, [[createMockedFeatureFlag("TestFeature", { enabled: true })]]]
423+
]);
424+
mockAppConfigurationClientGetSnapshot(snapshotResponses);
425+
mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotKVs);
420426
const connectionString = createMockedConnectionString();
421427
const settings = await load(connectionString, {
422428
featureFlagOptions: {

test/load.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -581,18 +581,22 @@ describe("load", function () {
581581

582582
it("should load key values from snapshot", async () => {
583583
const snapshotName = "Test";
584-
mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key"});
585-
mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotName, [[{key: "TestKey", value: "TestValue"}].map(createMockedKeyValue)]);
584+
const snapshotResponses = new Map([
585+
[snapshotName, { compositionType: "key" }]
586+
]);
587+
const snapshotKVs = new Map([
588+
[snapshotName, [[{key: "TestKey", value: "TestValue"}].map(createMockedKeyValue)]]]
589+
);
590+
mockAppConfigurationClientGetSnapshot(snapshotResponses);
591+
mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotKVs);
586592
const connectionString = createMockedConnectionString();
587593
const settings = await load(connectionString, {
588594
selectors: [{
589595
snapshotName: snapshotName
590596
}]
591597
});
592598
expect(settings).not.undefined;
593-
expect(settings).not.undefined;
594599
expect(settings.get("TestKey")).eq("TestValue");
595-
restoreMocks();
596600
});
597601
});
598602
/* eslint-enable @typescript-eslint/no-unused-expressions */

test/snapshotReference.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
/* eslint-disable @typescript-eslint/no-unused-expressions */
5+
import * as chai from "chai";
6+
import chaiAsPromised from "chai-as-promised";
7+
chai.use(chaiAsPromised);
8+
const expect = chai.expect;
9+
import { load } from "../src/index.js";
10+
import {
11+
mockAppConfigurationClientListConfigurationSettings,
12+
mockAppConfigurationClientGetSnapshot,
13+
mockAppConfigurationClientListConfigurationSettingsForSnapshot,
14+
restoreMocks,
15+
createMockedConnectionString,
16+
createMockedKeyValue,
17+
createMockedSnapshotReference,
18+
createMockedFeatureFlag,
19+
sleepInMs
20+
} from "./utils/testHelper.js";
21+
import * as uuid from "uuid";
22+
23+
const mockedKVs = [{
24+
key: "TestKey1",
25+
value: "Value1",
26+
}, {
27+
key: "TestKey2",
28+
value: "Value2",
29+
}
30+
].map(createMockedKeyValue);
31+
32+
mockedKVs.push(createMockedSnapshotReference("TestSnapshotRef", "TestSnapshot1"));
33+
34+
// TestSnapshot1
35+
const snapshot1 = [{
36+
key: "TestKey1",
37+
value: "Value1 in snapshot1",
38+
}
39+
].map(createMockedKeyValue);
40+
const testFeatureFlag = createMockedFeatureFlag("TestFeatureFlag");
41+
snapshot1.push(testFeatureFlag);
42+
43+
// TestSnapshot2
44+
const snapshot2 = [{
45+
key: "TestKey1",
46+
value: "Value1 in snapshot2",
47+
}
48+
].map(createMockedKeyValue);
49+
50+
describe("snapshot reference", function () {
51+
52+
beforeEach(() => {
53+
const snapshotResponses = new Map([
54+
["TestSnapshot1", { compositionType: "key" }],
55+
["TestSnapshot2", { compositionType: "key" }]]
56+
);
57+
const snapshotKVs = new Map([
58+
["TestSnapshot1", [snapshot1]],
59+
["TestSnapshot2", [snapshot2]]]
60+
);
61+
mockAppConfigurationClientGetSnapshot(snapshotResponses);
62+
mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotKVs);
63+
mockAppConfigurationClientListConfigurationSettings([mockedKVs]);
64+
});
65+
66+
afterEach(() => {
67+
restoreMocks();
68+
});
69+
70+
it("should resolve snapshot reference", async () => {
71+
const connectionString = createMockedConnectionString();
72+
const settings = await load(connectionString);
73+
expect(settings.get("TestKey1")).eq("Value1 in snapshot1");
74+
75+
// it should ignore feature flags in snapshot
76+
expect(settings.get(testFeatureFlag.key)).to.be.undefined;
77+
expect(settings.get("feature_management")).to.be.undefined;
78+
79+
// it should not load the snapshot reference key
80+
expect(settings.get("TestSnapshotRef")).to.be.undefined;
81+
});
82+
83+
it("should refresh when snapshot reference changes", async () => {
84+
const connectionString = createMockedConnectionString();
85+
const settings = await load(connectionString, {
86+
refreshOptions: {
87+
enabled: true,
88+
refreshIntervalInMs: 2000
89+
}
90+
});
91+
expect(settings.get("TestKey1")).eq("Value1 in snapshot1");
92+
93+
const setting = mockedKVs.find(kv => kv.key === "TestSnapshotRef");
94+
setting!.value = "{\"snapshot_name\":\"TestSnapshot2\"}";
95+
setting!.etag = uuid.v4();
96+
97+
await sleepInMs(2 * 1000 + 1);
98+
99+
await settings.refresh();
100+
101+
expect(settings.get("TestKey1")).eq("Value1 in snapshot2");
102+
});
103+
104+
});

0 commit comments

Comments
 (0)