Skip to content

Commit 3545ba2

Browse files
authored
support redis clusters (#27)
* support redis clusters * support redis cluster 2 * support redis cluster 3 * make example-cap-server deployable * dedicated integration mode for redis cluster * load credentials top-level so we can identify cluster mode * add remote url to example-cap-server http * make redis-service name generic * polish for manifest * update tests * remove remote http-client.env * less top-level invocation * less top-level invocation 2
1 parent 204db89 commit 3545ba2

File tree

8 files changed

+96
-62
lines changed

8 files changed

+96
-62
lines changed

Diff for: .eslintrc.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ root: true
33
# https://eslint.org/docs/rules/
44
env:
55
node: true
6-
es2020: true
6+
es2022: true
77
parserOptions:
8-
ecmaVersion: 2020
8+
ecmaVersion: 2022
99
plugins:
1010
- jest
1111
extends:

Diff for: example-cap-server/manifest.yml

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# https://docs.cloudfoundry.org/devguide/deploy-apps/manifest-attributes.html
2+
---
3+
applications:
4+
- name: example-cap-server
5+
path: ./gen/srv
6+
random-route: true
7+
memory: 256M
8+
buildpacks:
9+
- nodejs_buildpack
10+
services:
11+
- redis-service

Diff for: example-cap-server/package.json

+14-4
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,29 @@
33
"name": "example-cap-server",
44
"version": "1.0.0",
55
"scripts": {
6-
"start": "npm run copy-library && npm run serve",
7-
"copy-library": "npx shx rm -rf node_modules/@cap-js-community/feature-toggle-library && npx shx mkdir -p node_modules/@cap-js-community/feature-toggle-library && npx shx cp -R ../package.json ../index.cds ../cds-plugin.js ../src node_modules/@cap-js-community/feature-toggle-library",
6+
"start": "npm run serve",
87
"serve": "cds-serve",
9-
"build": "cds build --production",
8+
"copy-library": "npx shx rm -rf node_modules/@cap-js-community/feature-toggle-library && npx shx mkdir -p node_modules/@cap-js-community/feature-toggle-library && npx shx cp -R ../package.json ../index.cds ../cds-plugin.js ../src node_modules/@cap-js-community/feature-toggle-library",
9+
"build": "npm run build:cds && npm run build:deps && npm run build:copy-library",
10+
"build:cds": "cds build --production",
11+
"build:deps": "npm i --omit=dev --prefix=gen/srv",
12+
"build:copy-library": "npx shx rm -rf gen/srv/node_modules/@cap-js-community/feature-toggle-library && npx shx mkdir -p gen/srv/node_modules/@cap-js-community/feature-toggle-library && npx shx cp -R ../package.json ../index.cds ../cds-plugin.js ../src gen/srv/node_modules/@cap-js-community/feature-toggle-library",
13+
"deploy": "npm run build && cf push",
1014
"cloc": "npx cloc --vcs=git --exclude-ext=def --read-lang-def=cloc.def .",
1115
"upgrade": "npm up --save && npx shx rm -rf node_modules && npm i"
1216
},
17+
"engines": {
18+
"node": "^18.0.0",
19+
"npm": "^9.0.0"
20+
},
1321
"dependencies": {
1422
"@cap-js-community/feature-toggle-library": "*",
1523
"@sap/cds": "^7.2.0",
16-
"@sap/cds-dk": "^7.2.0",
1724
"express": "^4.18.2"
1825
},
26+
"devDependencies": {
27+
"@sap/cds-dk": "^7.2.0"
28+
},
1929
"cds": {
2030
"featureToggles": {
2131
"configFile": "./srv/feature/features.yaml",

Diff for: src/env.js

+10-6
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,14 @@
22

33
const { ENV } = require("./shared/static");
44

5-
const isOnCF = process.env[ENV.USER] === "vcap";
6-
75
class CfEnv {
6+
isOnCf;
7+
cfApp;
8+
cfServices;
9+
cfInstanceGuid;
10+
cfInstanceIp;
11+
cfInstanceIndex;
12+
813
static parseEnvVar(env, envVar) {
914
try {
1015
if (Object.prototype.hasOwnProperty.call(env, envVar)) {
@@ -14,6 +19,7 @@ class CfEnv {
1419
}
1520

1621
constructor(env = process.env) {
22+
this.isOnCf = env[ENV.USER] === "vcap";
1723
this.cfApp = CfEnv.parseEnvVar(env, ENV.CF_APP) || {};
1824
this.cfServices = CfEnv.parseEnvVar(env, ENV.CF_SERVICES) || {};
1925
this.cfInstanceGuid = env[ENV.CF_INSTANCE_GUID];
@@ -31,7 +37,7 @@ class CfEnv {
3137
/**
3238
* @return CfEnv
3339
*/
34-
static getInstance() {
40+
static get instance() {
3541
if (!CfEnv.__instance) {
3642
CfEnv.__instance = new CfEnv();
3743
}
@@ -53,7 +59,5 @@ class CfEnv {
5359

5460
module.exports = {
5561
CfEnv,
56-
57-
isOnCF,
58-
cfEnv: CfEnv.getInstance(),
62+
cfEnv: CfEnv.instance,
5963
};

Diff for: src/featureToggles.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const yaml = require("yaml");
2121
const redis = require("./redisWrapper");
2222
const { REDIS_INTEGRATION_MODE } = redis;
2323
const { Logger } = require("./logger");
24-
const { isOnCF, cfEnv } = require("./env");
24+
const { cfEnv } = require("./env");
2525
const { HandlerCollection } = require("./shared/handlerCollection");
2626
const { ENV, isObject, tryRequire } = require("./shared/static");
2727
const { promiseAllDone } = require("./shared/promiseAllDone");
@@ -743,7 +743,7 @@ class FeatureToggles {
743743
await redis.subscribe(this.__redisChannel);
744744
} catch (err) {
745745
logger.warning(
746-
isOnCF
746+
cfEnv.isOnCf
747747
? new VError(
748748
{
749749
name: VERROR_CLUSTER_NAME,

Diff for: src/redisWrapper.js

+39-25
Original file line numberDiff line numberDiff line change
@@ -6,43 +6,43 @@
66
const redis = require("redis");
77
const VError = require("verror");
88
const { Logger } = require("./logger");
9-
const { isOnCF, cfEnv } = require("./env");
9+
const { cfEnv } = require("./env");
1010
const { HandlerCollection } = require("./shared/handlerCollection");
1111
const { Semaphore } = require("./shared/semaphore");
1212

1313
const COMPONENT_NAME = "/RedisWrapper";
1414
const VERROR_CLUSTER_NAME = "RedisWrapperError";
1515

1616
const INTEGRATION_MODE = Object.freeze({
17+
CF_REDIS_CLUSTER: "CF_REDIS_CLUSTER",
1718
CF_REDIS: "CF_REDIS",
1819
LOCAL_REDIS: "LOCAL_REDIS",
1920
NO_REDIS: "NO_REDIS",
2021
});
22+
const CF_REDIS_SERVICE_LABEL = "redis-cache";
2123

2224
const logger = new Logger(COMPONENT_NAME);
25+
const watchedGetSetSemaphore = new Semaphore();
2326

2427
const MODE = Object.freeze({
2528
RAW: "raw",
2629
OBJECT: "object",
2730
});
2831

29-
let redisIsOnCF = isOnCF;
30-
let mainClient = null;
31-
let subscriberClient = null;
32-
let messageHandlers = new HandlerCollection();
33-
let integrationMode = null;
34-
35-
const watchedGetSetSemaphore = new Semaphore();
36-
32+
let messageHandlers;
33+
let mainClient;
34+
let subscriberClient;
35+
let integrationMode;
3736
const _reset = () => {
38-
redisIsOnCF = isOnCF;
37+
messageHandlers = new HandlerCollection();
3938
mainClient = null;
4039
subscriberClient = null;
41-
messageHandlers = new HandlerCollection();
40+
integrationMode = null;
4241
};
42+
_reset();
4343

4444
const _logErrorOnEvent = (err) =>
45-
redisIsOnCF ? logger.error(err) : logger.warning("%s | %O", err.message, VError.info(err));
45+
cfEnv.isOnCf ? logger.error(err) : logger.warning("%s | %O", err.message, VError.info(err));
4646

4747
const _subscribedMessageHandler = async (message, channel) => {
4848
const handlers = messageHandlers.getHandlers(channel);
@@ -78,22 +78,30 @@ const _localReconnectStrategy = () =>
7878
* Lazily create a new redis client. Client creation transparently handles both the Cloud Foundry "redis-cache" service
7979
* (hyperscaler option) and a local redis-server.
8080
*
81-
* @returns {RedisClient}
81+
* @returns {RedisClient|RedisCluster}
8282
* @private
8383
*/
8484
const _createClientBase = () => {
85-
if (redisIsOnCF) {
85+
if (cfEnv.isOnCf) {
8686
try {
87-
const credentials = cfEnv.cfServiceCredentialsForLabel("redis-cache");
8887
// NOTE: settings the user explicitly to empty resolves auth problems, see
8988
// https://github.com/go-redis/redis/issues/1343
90-
const url = credentials.uri.replace(/(?<=rediss:\/\/)[\w-]+?(?=:)/, "");
89+
const redisCredentials = cfEnv.cfServiceCredentialsForLabel(CF_REDIS_SERVICE_LABEL);
90+
const redisIsCluster = redisCredentials.cluster_mode;
91+
const url = redisCredentials.uri.replace(/(?<=rediss:\/\/)[\w-]+?(?=:)/, "");
92+
if (redisIsCluster) {
93+
return redis.createCluster({
94+
rootNodes: [{ url }],
95+
// https://github.com/redis/node-redis/issues/1782
96+
defaults: {
97+
password: redisCredentials.password,
98+
socket: { tls: redisCredentials.tls },
99+
},
100+
});
101+
}
91102
return redis.createClient({ url });
92103
} catch (err) {
93-
throw new VError(
94-
{ name: VERROR_CLUSTER_NAME, cause: err },
95-
"error during create client with redis-cache service"
96-
);
104+
throw new VError({ name: VERROR_CLUSTER_NAME, cause: err }, "error during create client with redis service");
97105
}
98106
} else {
99107
// NOTE: documentation is buried here https://github.com/redis/node-redis/blob/master/docs/client-configuration.md
@@ -144,7 +152,7 @@ const _clientErrorHandlerBase = async (client, err, clientName) => {
144152
*
145153
* Only one publisher is necessary for any number of channels.
146154
*
147-
* @returns {RedisClient}
155+
* @returns {RedisClient|RedisCluster}
148156
* @private
149157
*/
150158
const getMainClient = async () => {
@@ -163,7 +171,7 @@ const getMainClient = async () => {
163171
*
164172
* Only one subscriber is necessary for any number of channels.
165173
*
166-
* @returns {RedisClient}
174+
* @returns {RedisClient|RedisCluster}
167175
* @private
168176
*/
169177
const getSubscriberClient = async () => {
@@ -205,6 +213,12 @@ const sendCommand = async (command) => {
205213
}
206214

207215
try {
216+
const redisIsCluster = cfEnv.cfServiceCredentialsForLabel(CF_REDIS_SERVICE_LABEL).cluster_mode;
217+
if (redisIsCluster) {
218+
// NOTE: the cluster sendCommand API has a different signature, where it takes two optional args: firstKey and
219+
// isReadonly before the command
220+
return await mainClient.sendCommand(undefined, undefined, command);
221+
}
208222
return await mainClient.sendCommand(command);
209223
} catch (err) {
210224
throw new VError(
@@ -476,8 +490,9 @@ const removeMessageHandler = (channel, handler) => messageHandlers.removeHandler
476490
const removeAllMessageHandlers = (channel) => messageHandlers.removeAllHandlers(channel);
477491

478492
const _getIntegrationMode = async () => {
479-
if (redisIsOnCF) {
480-
return INTEGRATION_MODE.CF_REDIS;
493+
if (cfEnv.isOnCf) {
494+
const redisIsCluster = cfEnv.cfServiceCredentialsForLabel(CF_REDIS_SERVICE_LABEL).cluster_mode;
495+
return redisIsCluster ? INTEGRATION_MODE.CF_REDIS_CLUSTER : INTEGRATION_MODE.CF_REDIS;
481496
}
482497
try {
483498
await getMainClient();
@@ -521,7 +536,6 @@ module.exports = {
521536
_reset,
522537
_getMessageHandlers: () => messageHandlers,
523538
_getLogger: () => logger,
524-
_setRedisIsOnCF: (value) => (redisIsOnCF = value),
525539
_getMainClient: () => mainClient,
526540
_setMainClient: (value) => (mainClient = value),
527541
_getSubscriberClient: () => subscriberClient,

Diff for: test/__mocks__/env.js

+5-9
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,15 @@
11
"use strict";
22

3-
let cfEnv;
4-
let isOnCF;
5-
3+
let cfEnv = {};
64
const _reset = () => {
7-
isOnCF = false;
8-
cfEnv = {
9-
cfApp: {},
10-
cfServices: {},
11-
};
5+
cfEnv.isOnCf = false;
6+
cfEnv.cfApp = {};
7+
cfEnv.cfServices = {};
8+
cfEnv.cfServiceCredentialsForLabel = jest.fn().mockReturnValue({});
129
};
1310
_reset();
1411

1512
module.exports = {
1613
cfEnv,
17-
isOnCF,
1814
_reset,
1915
};

Diff for: test/redisWrapper.test.js

+13-14
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
11
"use strict";
22

3-
const mockCfEnv = {
4-
cfServiceCredentialsForLabel: jest.fn(),
5-
};
6-
jest.mock("../src/env", () => ({
7-
isOnCF: false,
8-
cfEnv: mockCfEnv,
9-
}));
3+
const envMock = require("../src/env");
4+
jest.mock("../src/env", () => require("./__mocks__/env"));
105

116
const mockMessageHandler = jest.fn();
127
const mockMessageHandlerTwo = jest.fn();
@@ -52,6 +47,7 @@ describe("redis wrapper test", () => {
5247
afterEach(() => {
5348
jest.clearAllMocks();
5449
redisWrapper._._reset();
50+
envMock._reset();
5551
});
5652

5753
it("_createMainClientAndConnect/_createSubscriberClientAndConnect shortcut", async () => {
@@ -69,7 +65,7 @@ describe("redis wrapper test", () => {
6965
});
7066

7167
it("_createClientBase local", async () => {
72-
redisWrapper._._setRedisIsOnCF(false);
68+
envMock.cfEnv.isOnCf = false;
7369
const client = redisWrapper._._createClientBase();
7470

7571
expect(redis.createClient).toHaveBeenCalledTimes(1);
@@ -88,15 +84,16 @@ describe("redis wrapper test", () => {
8884
});
8985

9086
it("_createClientBase on CF", async () => {
91-
redisWrapper._._setRedisIsOnCF(true);
9287
const mockUrl = "rediss://BAD_USERNAME:pwd@mockUrl";
9388
const mockUrlUsable = mockUrl.replace("BAD_USERNAME", "");
94-
mockCfEnv.cfServiceCredentialsForLabel.mockImplementationOnce(() => ({ uri: mockUrl }));
89+
90+
envMock.cfEnv.isOnCf = true;
91+
envMock.cfEnv.cfServiceCredentialsForLabel.mockReturnValueOnce({ uri: mockUrl });
9592

9693
const client = redisWrapper._._createClientBase();
9794

98-
expect(mockCfEnv.cfServiceCredentialsForLabel).toHaveBeenCalledTimes(1);
99-
expect(mockCfEnv.cfServiceCredentialsForLabel).toHaveBeenCalledWith("redis-cache");
95+
expect(envMock.cfEnv.cfServiceCredentialsForLabel).toHaveBeenCalledTimes(1);
96+
expect(envMock.cfEnv.cfServiceCredentialsForLabel).toHaveBeenCalledWith("redis-cache");
10097
expect(redis.createClient).toHaveBeenCalledTimes(1);
10198
expect(redis.createClient).toHaveBeenCalledWith({ url: mockUrlUsable });
10299
expect(client).toBe(mockClient);
@@ -424,9 +421,11 @@ describe("redis wrapper test", () => {
424421
});
425422

426423
it("_subscribedMessageHandler error", async () => {
427-
redisWrapper._._setRedisIsOnCF(true);
428424
const mockUrl = "rediss://BAD_USERNAME:pwd@mockUrl";
429-
mockCfEnv.cfServiceCredentialsForLabel.mockImplementationOnce(() => ({ uri: mockUrl }));
425+
426+
envMock.cfEnv.isOnCf = true;
427+
envMock.cfEnv.cfServiceCredentialsForLabel.mockReturnValueOnce({ uri: mockUrl });
428+
430429
redisWrapper.registerMessageHandler(channel, mockMessageHandler);
431430
redisWrapper.registerMessageHandler(channel, mockMessageHandlerTwo);
432431
await redisWrapper.subscribe(channel);

0 commit comments

Comments
 (0)