Skip to content

Commit 7c6525b

Browse files
authored
Fb/better change handling (#65)
* wip 1 * wip 2 * wip 3 * wip 4 * wip 5 * wip 6 * wip 7 * wip 8 * wip 9 * wip 10
1 parent 272e670 commit 7c6525b

File tree

4 files changed

+148
-49
lines changed

4 files changed

+148
-49
lines changed

src/featureToggles.js

Lines changed: 55 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,9 @@ const { REDIS_INTEGRATION_MODE } = redis;
2323
const cfEnv = require("./shared/env");
2424
const { Logger } = require("./shared/logger");
2525
const { HandlerCollection } = require("./shared/handlerCollection");
26-
const { promiseAllDone } = require("./shared/promiseAllDone");
2726
const { LimitedLazyCache } = require("./shared/cache");
2827
const { Semaphore } = require("./shared/semaphore");
29-
const { ENV, isObject, tryRequire, tryPathReadable } = require("./shared/static");
28+
const { ENV, isObject, tryRequire, tryPathReadable, tryJsonParse } = require("./shared/static");
3029

3130
const ENV_UNIQUE_NAME = process.env[ENV.UNIQUE_NAME];
3231
const DEFAULT_REDIS_CHANNEL = process.env[ENV.REDIS_CHANNEL] || "features";
@@ -1165,7 +1164,7 @@ class FeatureToggles {
11651164
* @returns {Array<ChangeEntry>}
11661165
*/
11671166
static _deserializeChangesFromRefreshMessage(message) {
1168-
return JSON.parse(message);
1167+
return tryJsonParse(message);
11691168
}
11701169

11711170
/**
@@ -1229,15 +1228,44 @@ class FeatureToggles {
12291228
}
12301229

12311230
/**
1232-
* Handler for refresh message.
1231+
* Handler for message with change entries.
12331232
*/
12341233
async _messageHandler(message) {
1235-
let featureKey, newValue, scopeMap, options;
1236-
try {
1237-
const changeEntries = FeatureToggles._deserializeChangesFromRefreshMessage(message);
1238-
await promiseAllDone(
1239-
changeEntries.map(async (changeEntry) => {
1240-
({ featureKey, newValue, scopeMap, options } = changeEntry);
1234+
const changeEntries = FeatureToggles._deserializeChangesFromRefreshMessage(message);
1235+
if (!Array.isArray(changeEntries)) {
1236+
logger.error(
1237+
new VError(
1238+
{
1239+
name: VERROR_CLUSTER_NAME,
1240+
info: {
1241+
channel: this.__redisChannel,
1242+
message,
1243+
},
1244+
},
1245+
"error during message deserialization"
1246+
)
1247+
);
1248+
return;
1249+
}
1250+
1251+
await Promise.all(
1252+
changeEntries.map(async (changeEntry) => {
1253+
try {
1254+
if (!isObject(changeEntry) || changeEntry.featureKey === undefined || changeEntry.newValue === undefined) {
1255+
logger.warning(
1256+
new VError(
1257+
{
1258+
name: VERROR_CLUSTER_NAME,
1259+
info: {
1260+
changeEntry: JSON.stringify(changeEntry),
1261+
},
1262+
},
1263+
"received and ignored change entry"
1264+
)
1265+
);
1266+
return;
1267+
}
1268+
const { featureKey, newValue, scopeMap, options } = changeEntry;
12411269

12421270
const scopeKey = FeatureToggles.getScopeKey(scopeMap);
12431271
const oldValue = FeatureToggles._getFeatureValueForScopeAndStateAndFallback(
@@ -1276,25 +1304,23 @@ class FeatureToggles {
12761304
scopeKey,
12771305
options
12781306
);
1279-
})
1280-
);
1281-
} catch (err) {
1282-
logger.error(
1283-
new VError(
1284-
{
1285-
name: VERROR_CLUSTER_NAME,
1286-
cause: err,
1287-
info: {
1288-
channel: this.__redisChannel,
1289-
message,
1290-
...(featureKey && { featureKey }),
1291-
...(scopeMap && { scopeMap: JSON.stringify(scopeMap) }),
1292-
},
1293-
},
1294-
"error during message handling"
1295-
)
1296-
);
1297-
}
1307+
} catch (err) {
1308+
logger.error(
1309+
new VError(
1310+
{
1311+
name: VERROR_CLUSTER_NAME,
1312+
cause: err,
1313+
info: {
1314+
channel: this.__redisChannel,
1315+
changeEntry: JSON.stringify(changeEntry),
1316+
},
1317+
},
1318+
"error during message handling"
1319+
)
1320+
);
1321+
}
1322+
})
1323+
);
12981324
}
12991325

13001326
async _changeRemoteFeatureValue(featureKey, newValue, scopeMap, options) {

src/shared/static.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ const tryRequire = (module) => {
2828
} catch (err) {} // eslint-disable-line no-empty
2929
};
3030

31+
const tryJsonParse = (...args) => {
32+
try {
33+
return JSON.parse(...args);
34+
} catch (err) {} // eslint-disable-line no-empty
35+
};
36+
3137
const pathReadable = async (path) => (await accessAsync(path, fs.constants.R_OK)) ?? true;
3238

3339
const tryPathReadable = async (path) => {
@@ -43,6 +49,7 @@ module.exports = {
4349
isNull,
4450
isObject,
4551
tryRequire,
52+
tryJsonParse,
4653
pathReadable,
4754
tryPathReadable,
4855
};

test/__snapshots__/featureToggles.test.js.snap

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,3 @@ exports[`feature toggles test basic apis initializeFeatureToggles warns for inva
218218
},
219219
}
220220
`;
221-
222-
exports[`feature toggles test basic apis initializeFeatureToggles warns for invalid values 3`] = `
223-
{
224-
"validationErrors": "[{"featureKey":"test/feature_b","errorMessage":"value null is not allowed"},{"featureKey":"test/feature_c","errorMessage":"value \\"{0}\\" has invalid type {1}, must be {2}","errorMessageValues":["1","string","number"]}]",
225-
}
226-
`;

test/featureToggles.test.js

Lines changed: 86 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"use strict";
22

3+
const util = require("util");
4+
35
const VError = require("verror");
46
const yaml = require("yaml");
57
const featureTogglesModule = require("../src/featureToggles");
@@ -25,6 +27,9 @@ jest.mock("../src/shared/env", () => require("./__mocks__/env"));
2527
const redisWrapperMock = require("../src/redisWrapper");
2628
jest.mock("../src/redisWrapper", () => require("./__mocks__/redisWrapper"));
2729

30+
const outputFromErrorLogger = (calls) =>
31+
calls.map((args) => util.format("%s\n%O", args[0], VError.info(args[0]))).join("\n");
32+
2833
const configToFallbackValues = (config) =>
2934
Object.fromEntries(Object.entries(config).map(([key, { fallbackValue }]) => [key, fallbackValue]));
3035
const configToActiveKeys = (config) =>
@@ -131,16 +136,12 @@ describe("feature toggles test", () => {
131136
`[]`
132137
);
133138
expect(loggerSpy.error).toHaveBeenCalledTimes(1);
134-
expect(loggerSpy.error.mock.calls[0]).toMatchInlineSnapshot(`
135-
[
136-
[FeatureTogglesError: scope exceeds allowed number of keys],
137-
]
138-
`);
139-
expect(VError.info(loggerSpy.error.mock.calls[0][0])).toMatchInlineSnapshot(`
139+
expect(outputFromErrorLogger(loggerSpy.error.mock.calls)).toMatchInlineSnapshot(`
140+
"FeatureTogglesError: scope exceeds allowed number of keys
140141
{
141-
"maxKeys": 4,
142-
"scopeMap": "{"tenantId":"t1","label":"l1","bla":"b1","naa":"n1","xxx":"x1"}",
143-
}
142+
scopeMap: '{"tenantId":"t1","label":"l1","bla":"b1","naa":"n1","xxx":"x1"}',
143+
maxKeys: 4
144+
}"
144145
`);
145146
});
146147

@@ -288,11 +289,12 @@ describe("feature toggles test", () => {
288289

289290
expect(loggerSpy.warning).toHaveBeenCalledTimes(1);
290291
expect(loggerSpy.warning).toHaveBeenCalledWith(expect.any(VError));
291-
const [loggedErr] = loggerSpy.warning.mock.calls[0];
292-
expect(loggedErr).toMatchInlineSnapshot(
293-
`[FeatureTogglesError: found invalid fallback values during initialization]`
294-
);
295-
expect(VError.info(loggedErr)).toMatchSnapshot();
292+
expect(outputFromErrorLogger(loggerSpy.warning.mock.calls)).toMatchInlineSnapshot(`
293+
"FeatureTogglesError: found invalid fallback values during initialization
294+
{
295+
validationErrors: '[{"featureKey":"test/feature_b","errorMessage":"value null is not allowed"},{"featureKey":"test/feature_c","errorMessage":"value \\\\"{0}\\\\" has invalid type {1}, must be {2}","errorMessageValues":["1","string","number"]}]'
296+
}"
297+
`);
296298
expect(loggerSpy.error).not.toHaveBeenCalled();
297299
});
298300

@@ -1151,4 +1153,74 @@ describe("feature toggles test", () => {
11511153
expect(loggerSpy.error).not.toHaveBeenCalled();
11521154
});
11531155
});
1156+
1157+
describe("message handling", () => {
1158+
beforeEach(async () => {
1159+
await featureToggles.initializeFeatures({ config: mockConfig });
1160+
});
1161+
1162+
it("empty array should be handled fine", async () => {
1163+
await redisWrapperMock.publishMessage(featureToggles.__redisChannel, "[]");
1164+
expect(loggerSpy.warning).toHaveBeenCalledTimes(0);
1165+
expect(loggerSpy.error).toHaveBeenCalledTimes(0);
1166+
});
1167+
1168+
it("error in case message is not a serialized array", async () => {
1169+
await redisWrapperMock.publishMessage(featureToggles.__redisChannel, "hello world!");
1170+
expect(loggerSpy.warning).toHaveBeenCalledTimes(0);
1171+
expect(loggerSpy.error).toHaveBeenCalledTimes(1);
1172+
expect(outputFromErrorLogger(loggerSpy.error.mock.calls)).toMatchInlineSnapshot(`
1173+
"FeatureTogglesError: error during message deserialization
1174+
{ channel: 'feature-channel', message: 'hello world!' }"
1175+
`);
1176+
});
1177+
1178+
it("invalid change entries are ignored but logged", async () => {
1179+
const changeEntries = [{ featureKey: FEATURE.C, newValue: 10 }];
1180+
1181+
expect(featureToggles.getFeatureValue(FEATURE.C)).toBe(mockFallbackValues[FEATURE.C]);
1182+
1183+
await redisWrapperMock.publishMessage(featureToggles.__redisChannel, JSON.stringify(changeEntries));
1184+
1185+
expect(loggerSpy.warning).toHaveBeenCalledTimes(1);
1186+
expect(outputFromErrorLogger(loggerSpy.warning.mock.calls)).toMatchInlineSnapshot(`
1187+
"FeatureTogglesError: received and ignored invalid value from message
1188+
{
1189+
validationErrors: '[{"featureKey":"test/feature_c","scopeKey":"//","errorMessage":"value \\\\"{0}\\\\" has invalid type {1}, must be {2}","errorMessageValues":[10,"number","string"]}]'
1190+
}"
1191+
`);
1192+
1193+
expect(featureToggles.getFeatureValue(FEATURE.C)).toBe(mockFallbackValues[FEATURE.C]);
1194+
});
1195+
1196+
it("all change entries are processed even if one fails", async () => {
1197+
const scopeMap = { tenant: "testing" };
1198+
const changeEntries = [
1199+
{ featureKey: FEATURE.C, newValue: "modified" },
1200+
{},
1201+
null,
1202+
"bla",
1203+
{ featureKey: FEATURE.E, newValue: 9, scopeMap },
1204+
];
1205+
1206+
expect(featureToggles.getFeatureValue(FEATURE.C)).toBe(mockFallbackValues[FEATURE.C]);
1207+
expect(featureToggles.getFeatureValue(FEATURE.E, scopeMap)).toBe(mockFallbackValues[FEATURE.E]);
1208+
1209+
await redisWrapperMock.publishMessage(featureToggles.__redisChannel, JSON.stringify(changeEntries));
1210+
1211+
expect(loggerSpy.warning).toHaveBeenCalledTimes(3);
1212+
expect(outputFromErrorLogger(loggerSpy.warning.mock.calls)).toMatchInlineSnapshot(`
1213+
"FeatureTogglesError: received and ignored change entry
1214+
{ changeEntry: '{}' }
1215+
FeatureTogglesError: received and ignored change entry
1216+
{ changeEntry: 'null' }
1217+
FeatureTogglesError: received and ignored change entry
1218+
{ changeEntry: '"bla"' }"
1219+
`);
1220+
expect(loggerSpy.error).toHaveBeenCalledTimes(0);
1221+
1222+
expect(featureToggles.getFeatureValue(FEATURE.C)).toMatchInlineSnapshot(`"modified"`);
1223+
expect(featureToggles.getFeatureValue(FEATURE.E, scopeMap)).toMatchInlineSnapshot(`9`);
1224+
});
1225+
});
11541226
});

0 commit comments

Comments
 (0)