Skip to content

Commit 38aa8c1

Browse files
authored
Merge pull request #163 from drktfl/feat/redis-sentinel-support
Add Redis Sentinel support
2 parents e53601e + 9403143 commit 38aa8c1

File tree

4 files changed

+216
-13
lines changed

4 files changed

+216
-13
lines changed

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,31 @@ const mainClient = await RedisClient.create().createMainClientAndConnect(options
399399

400400
For details on Redis `createClient` configuration options see [Redis Client Configuration](https://github.com/redis/node-redis/blob/master/docs/client-configuration.md).
401401

402+
### Redis Sentinel Support
403+
404+
Redis Sentinel mode is activated when `credentials.sentinel_nodes` is present and takes priority over cluster mode.
405+
406+
```json
407+
{
408+
"cds": {
409+
"requires": {
410+
"redis": {
411+
"credentials": {
412+
"sentinel_nodes": [
413+
{ "host": "sentinel1.example.com", "port": 26379 },
414+
{ "host": "sentinel2.example.com", "port": 26379 }
415+
],
416+
"master_name": "myprimary"
417+
}
418+
}
419+
}
420+
}
421+
}
422+
```
423+
424+
The `master_name` identifies which Redis master the Sentinels monitor. Alternatively, the master name can be provided as a URI fragment in `credentials.uri` (e.g., `redis://host#myprimary`).
425+
If `sentinel_nodes[].port` is omitted, it defaults to `26379`. Both `host` and `hostname` are accepted for node addresses.
426+
402427
## Local HTML5 Repository
403428

404429
Developing HTML5 apps against hybrid environments including Approuter component requires a local HTML5 repository to directly test the changes to UI5 applications without deployment to a remote HTML5 repository.

src/redis-client/RedisClient.js

Lines changed: 76 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const TIMEOUT_SHUTDOWN = 2500;
99

1010
class RedisClient {
1111
#clusterClient = false;
12+
#sentinelClient = false;
1213
#beforeCloseHandler;
1314
constructor(name, env) {
1415
this.name = name;
@@ -105,20 +106,25 @@ class RedisClient {
105106
createClientBase(redisOptions = {}) {
106107
const { credentials, options } =
107108
(this.env ? cds.env.requires[`redis-${this.env}`] : undefined) || cds.env.requires["redis"] || {};
108-
const socket = {
109-
host: credentials?.hostname ?? "127.0.0.1",
110-
tls: !!credentials?.tls,
111-
port: credentials?.port ?? 6379,
112-
...options?.socket,
113-
...redisOptions.socket,
114-
};
115-
const socketOptions = {
116-
...options,
117-
...redisOptions,
118-
password: redisOptions?.password ?? options?.password ?? credentials?.password,
119-
socket,
120-
};
109+
121110
try {
111+
if (credentials?.sentinel_nodes?.length > 0) {
112+
return this.createSentinelClient(credentials, options, redisOptions);
113+
}
114+
115+
const socket = {
116+
host: credentials?.hostname ?? "127.0.0.1",
117+
tls: !!credentials?.tls,
118+
port: credentials?.port ?? 6379,
119+
...options?.socket,
120+
...redisOptions.socket,
121+
};
122+
const socketOptions = {
123+
...options,
124+
...redisOptions,
125+
password: redisOptions?.password ?? options?.password ?? credentials?.password,
126+
socket,
127+
};
122128
if (credentials?.cluster_mode) {
123129
this.#clusterClient = true;
124130
return redis.createCluster({
@@ -132,6 +138,59 @@ class RedisClient {
132138
}
133139
}
134140

141+
createSentinelClient(credentials, options, redisOptions) {
142+
const masterName = this.extractMasterName(credentials);
143+
const sentinelNodes = credentials.sentinel_nodes.map((node) => ({
144+
host: node.host ?? node.hostname,
145+
port: node.port ?? 26379,
146+
}));
147+
const clientOptions = {
148+
...options,
149+
...redisOptions,
150+
password: redisOptions?.password ?? options?.password ?? credentials?.password,
151+
socket: {
152+
tls: !!credentials?.tls,
153+
...options?.socket,
154+
...redisOptions?.socket,
155+
},
156+
};
157+
this.#sentinelClient = true;
158+
this.log.info("Creating Redis Sentinel client", { masterName, nodeCount: sentinelNodes.length });
159+
return redis.createSentinel({
160+
name: masterName,
161+
sentinelRootNodes: sentinelNodes,
162+
nodeClientOptions: clientOptions,
163+
sentinelClientOptions: clientOptions,
164+
passthroughClientErrorEvents: true,
165+
});
166+
}
167+
168+
/**
169+
* Extracts the Sentinel master name from credentials.
170+
* Priority: master_name field > URI fragment
171+
* @param {Object} credentials - Redis credentials
172+
* @returns {string} Master name
173+
* @throws {Error} If master name cannot be determined
174+
*/
175+
extractMasterName(credentials) {
176+
if (credentials?.master_name) {
177+
return credentials.master_name;
178+
}
179+
if (credentials?.uri) {
180+
try {
181+
const url = new URL(credentials.uri);
182+
if (url.hash && url.hash.length > 1) {
183+
return url.hash.slice(1);
184+
}
185+
} catch (e) {
186+
this.log.warn("Failed to parse master name from URI", e.message);
187+
}
188+
}
189+
throw new Error(
190+
"Redis Sentinel master name not found. Provide credentials.master_name or include #mastername in credentials.uri",
191+
);
192+
}
193+
135194
subscribeChannel(options, channel, subscribeHandler) {
136195
this.subscribedChannels[channel] = subscribeHandler;
137196
const errorHandlerCreateClient = (err) => {
@@ -251,6 +310,10 @@ class RedisClient {
251310
return this.#clusterClient;
252311
}
253312

313+
get isSentinel() {
314+
return this.#sentinelClient;
315+
}
316+
254317
static create(name = "default", env) {
255318
env ??= name;
256319
RedisClient._create ??= {};

test/mocks/redis.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,14 @@ module.exports = {
118118
client.options = options;
119119
return client;
120120
}),
121+
createSentinel: jest.fn((options) => {
122+
if (createClientError) {
123+
createClientError = false;
124+
throw new Error("create sentinel error");
125+
}
126+
client.options = options;
127+
return client;
128+
}),
121129
commandOptions: jest.fn(() => {
122130
return {};
123131
}),

test/redis-client/redis-client.test.js

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,4 +123,111 @@ describe("Redis Client", () => {
123123
const result = await redisClient.publishMessage({}, "test", "message");
124124
expect(result).toBeUndefined();
125125
});
126+
127+
describe("Sentinel Mode", () => {
128+
beforeEach(async () => {
129+
jest.clearAllMocks();
130+
await RedisClient.closeAllClients();
131+
});
132+
133+
it("creates Sentinel client when sentinel_nodes configured", async () => {
134+
cds.env.requires.redis = {
135+
credentials: {
136+
sentinel_nodes: [
137+
{ hostname: "sentinel1.example.com", port: 26379 },
138+
{ hostname: "sentinel2.example.com", port: 26379 },
139+
],
140+
uri: "redis://:secret@sentinel1.example.com:26379#mymaster",
141+
password: "secret",
142+
tls: true,
143+
},
144+
};
145+
146+
const redisClient = RedisClient.create("sentinel-test");
147+
const client = await redisClient.createMainClientAndConnect();
148+
149+
expect(client).toBeDefined();
150+
expect(redis.createSentinel).toHaveBeenCalledWith({
151+
name: "mymaster",
152+
sentinelRootNodes: [
153+
{ host: "sentinel1.example.com", port: 26379 },
154+
{ host: "sentinel2.example.com", port: 26379 },
155+
],
156+
nodeClientOptions: expect.objectContaining({
157+
password: "secret",
158+
socket: expect.objectContaining({ tls: true }),
159+
}),
160+
sentinelClientOptions: expect.objectContaining({
161+
password: "secret",
162+
}),
163+
passthroughClientErrorEvents: true,
164+
});
165+
expect(redisClient.isSentinel).toBe(true);
166+
expect(redisClient.isCluster).toBe(false);
167+
});
168+
169+
it("prefers master_name field over URI fragment", async () => {
170+
cds.env.requires.redis = {
171+
credentials: {
172+
sentinel_nodes: [{ host: "sentinel.local", port: 26379 }],
173+
master_name: "explicit-master",
174+
uri: "redis://sentinel.local#uri-master",
175+
},
176+
};
177+
178+
const redisClient = RedisClient.create("master-name-test");
179+
await redisClient.createMainClientAndConnect();
180+
181+
expect(redis.createSentinel).toHaveBeenCalledWith(expect.objectContaining({ name: "explicit-master" }));
182+
});
183+
184+
it("uses default port 26379 when not specified", async () => {
185+
cds.env.requires.redis = {
186+
credentials: {
187+
sentinel_nodes: [{ hostname: "sentinel.local" }],
188+
master_name: "mymaster",
189+
},
190+
};
191+
192+
const redisClient = RedisClient.create("default-port-test");
193+
await redisClient.createMainClientAndConnect();
194+
195+
expect(redis.createSentinel).toHaveBeenCalledWith(
196+
expect.objectContaining({
197+
sentinelRootNodes: [{ host: "sentinel.local", port: 26379 }],
198+
}),
199+
);
200+
});
201+
202+
it("returns undefined if master name not found", async () => {
203+
cds.env.requires.redis = {
204+
credentials: {
205+
sentinel_nodes: [{ hostname: "sentinel.local" }],
206+
},
207+
};
208+
209+
const redisClient = RedisClient.create("no-master-test");
210+
const client = await redisClient.createMainClientAndConnect();
211+
expect(client).toBeUndefined();
212+
});
213+
214+
it("prioritizes sentinel over cluster mode", async () => {
215+
cds.env.requires.redis = {
216+
credentials: {
217+
sentinel_nodes: [{ hostname: "sentinel.local" }],
218+
master_name: "mymaster",
219+
cluster_mode: true,
220+
},
221+
};
222+
223+
const redisClient = RedisClient.create("priority-test");
224+
await redisClient.createMainClientAndConnect();
225+
226+
expect(redis.createSentinel).toHaveBeenCalled();
227+
expect(redis.createCluster).not.toHaveBeenCalled();
228+
expect(redisClient.isSentinel).toBe(true);
229+
});
230+
});
126231
});
232+
233+

0 commit comments

Comments
 (0)