Skip to content

Commit 474feba

Browse files
committed
feat: add Redis Sentinel URL support (#213)
Implements redis+sentinel:// URL scheme for high availability Redis deployments with automatic failover via Sentinel. URL format: redis+sentinel://[username:password@]host1:port1,host2:port2/service_name[/database] Features: - URL parsing with SentinelConfig.fromUrl() - Support for multiple comma-separated Sentinel hosts - IPv6 address handling in bracket notation - Authentication (username/password) - Database selection - Default values (port 26379, service "mymaster") - Integration with RedisConnectionManager via JedisSentinelPool Implementation details: - SentinelConfig: Parse and store Sentinel connection parameters - RedisConnectionManager: Detect redis+sentinel:// URLs and create JedisSentinelPool instead of standard JedisPool - Pool<Jedis> interface allows transparent handling of both pool types - Tests match Python implementation from PR #385 Python reference: redisvl/redis/connection.py - _parse_sentinel_url() Java files: - core/src/main/java/com/redis/vl/redis/SentinelConfig.java - core/src/main/java/com/redis/vl/redis/RedisConnectionManager.java:55-58 - core/src/test/java/com/redis/vl/redis/SentinelUrlParsingTest.java
1 parent 379fa2b commit 474feba

File tree

3 files changed

+458
-2
lines changed

3 files changed

+458
-2
lines changed

core/src/main/java/com/redis/vl/redis/RedisConnectionManager.java

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@
22

33
import java.io.Closeable;
44
import java.net.URI;
5+
import java.util.Set;
56
import java.util.function.Function;
7+
import java.util.stream.Collectors;
68
import lombok.extern.slf4j.Slf4j;
79
import redis.clients.jedis.Jedis;
810
import redis.clients.jedis.JedisPool;
911
import redis.clients.jedis.JedisPoolConfig;
12+
import redis.clients.jedis.JedisSentinelPool;
13+
import redis.clients.jedis.util.Pool;
1014

1115
/** Manages Redis connections and provides connection pooling. */
1216
@Slf4j
1317
public class RedisConnectionManager implements Closeable {
1418

15-
private final JedisPool jedisPool;
19+
private final Pool<Jedis> jedisPool;
1620

1721
/**
1822
* Create a new connection manager with the given configuration.
@@ -24,13 +28,34 @@ public RedisConnectionManager(RedisConnectionConfig config) {
2428
log.info("Redis connection manager initialized");
2529
}
2630

31+
/**
32+
* Create a new connection manager with Sentinel configuration.
33+
*
34+
* @param config The Sentinel connection configuration
35+
*/
36+
public RedisConnectionManager(SentinelConfig config) {
37+
this.jedisPool = createJedisSentinelPool(config);
38+
log.info("Redis Sentinel connection manager initialized");
39+
}
40+
2741
/**
2842
* Create a connection manager from a URI.
2943
*
30-
* @param uri The Redis connection URI (e.g., redis://localhost:6379)
44+
* <p>Supports both standard Redis URLs and Sentinel URLs:
45+
*
46+
* <ul>
47+
* <li>redis://[username:password@]host:port[/database] - Standard Redis connection
48+
* <li>redis+sentinel://[username:password@]host1:port1,host2:port2/service_name[/database] -
49+
* Sentinel connection
50+
* </ul>
51+
*
52+
* @param uri The Redis connection URI
3153
* @return A new RedisConnectionManager instance
3254
*/
3355
public static RedisConnectionManager from(String uri) {
56+
if (uri != null && uri.startsWith("redis+sentinel://")) {
57+
return new RedisConnectionManager(SentinelConfig.fromUrl(uri));
58+
}
3459
return new RedisConnectionManager(RedisConnectionConfig.fromUri(uri));
3560
}
3661

@@ -72,6 +97,34 @@ private JedisPool createJedisPool(RedisConnectionConfig config) {
7297
}
7398
}
7499

100+
/** Create JedisSentinelPool from Sentinel configuration */
101+
private JedisSentinelPool createJedisSentinelPool(SentinelConfig config) {
102+
// Convert HostPort list to Set<String> in "host:port" format
103+
Set<String> sentinelHosts =
104+
config.getSentinelHosts().stream()
105+
.map(hp -> hp.getHost() + ":" + hp.getPort())
106+
.collect(Collectors.toSet());
107+
108+
// Create pool config with defaults
109+
JedisPoolConfig poolConfig = new JedisPoolConfig();
110+
poolConfig.setMaxTotal(10);
111+
poolConfig.setMaxIdle(5);
112+
poolConfig.setMinIdle(1);
113+
poolConfig.setTestOnBorrow(true);
114+
115+
// Create Sentinel pool
116+
return new JedisSentinelPool(
117+
config.getServiceName(),
118+
sentinelHosts,
119+
poolConfig,
120+
config.getConnectionTimeout(),
121+
config.getSocketTimeout(),
122+
config.getUsername(),
123+
config.getPassword(),
124+
config.getDatabase() != null ? config.getDatabase() : 0,
125+
null); // clientName
126+
}
127+
75128
/**
76129
* Check if the connection manager is connected.
77130
*
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
package com.redis.vl.redis;
2+
3+
import java.util.ArrayList;
4+
import java.util.Collections;
5+
import java.util.List;
6+
import lombok.Builder;
7+
import lombok.Getter;
8+
import lombok.Singular;
9+
10+
/**
11+
* Configuration for Redis Sentinel connections.
12+
*
13+
* <p>Supports the redis+sentinel:// URL scheme for high availability Redis deployments:
14+
* redis+sentinel://[username:password@]host1:port1,host2:port2/service_name[/db]
15+
*
16+
* <p>Python reference: redisvl/redis/connection.py - _parse_sentinel_url
17+
*/
18+
@Builder
19+
public class SentinelConfig {
20+
21+
/** List of Sentinel host:port pairs */
22+
@Singular private final List<HostPort> sentinelHosts;
23+
24+
/** Sentinel service/master name (default: "mymaster") */
25+
@Getter @Builder.Default private final String serviceName = "mymaster";
26+
27+
/** Redis database number (optional) */
28+
@Getter private final Integer database;
29+
30+
/** Username for authentication (optional) */
31+
@Getter private final String username;
32+
33+
/** Password for authentication (optional) */
34+
@Getter private final String password;
35+
36+
/** Connection timeout in milliseconds */
37+
@Getter @Builder.Default private final int connectionTimeout = 2000;
38+
39+
/** Socket timeout in milliseconds */
40+
@Getter @Builder.Default private final int socketTimeout = 2000;
41+
42+
/**
43+
* Get an unmodifiable view of the Sentinel hosts list.
44+
*
45+
* @return Unmodifiable list of Sentinel host:port pairs
46+
*/
47+
public List<HostPort> getSentinelHosts() {
48+
return Collections.unmodifiableList(sentinelHosts);
49+
}
50+
51+
/**
52+
* Parse a Sentinel URL into a SentinelConfig.
53+
*
54+
* <p>URL format: redis+sentinel://[username:password@]host1:port1,host2:port2/service_name[/db]
55+
*
56+
* @param url Sentinel URL to parse
57+
* @return Parsed SentinelConfig
58+
* @throws IllegalArgumentException if URL is invalid
59+
*/
60+
public static SentinelConfig fromUrl(String url) {
61+
if (url == null || !url.startsWith("redis+sentinel://")) {
62+
throw new IllegalArgumentException(
63+
"URL must start with redis+sentinel:// scheme. Got: " + url);
64+
}
65+
66+
try {
67+
// Remove scheme prefix
68+
String remaining = url.substring("redis+sentinel://".length());
69+
70+
// Extract username and password from userInfo (before @)
71+
String username = null;
72+
String password = null;
73+
String hostsString;
74+
75+
int atIndex = remaining.indexOf("@");
76+
if (atIndex > 0) {
77+
String userInfo = remaining.substring(0, atIndex);
78+
remaining = remaining.substring(atIndex + 1);
79+
80+
String[] userInfoParts = userInfo.split(":", 2);
81+
if (userInfoParts.length == 2) {
82+
username = userInfoParts[0].isEmpty() ? null : userInfoParts[0];
83+
password = userInfoParts[1].isEmpty() ? null : userInfoParts[1];
84+
} else if (userInfoParts.length == 1 && !userInfoParts[0].isEmpty()) {
85+
username = userInfoParts[0];
86+
}
87+
}
88+
89+
// Extract hosts (before first /)
90+
int slashIndex = remaining.indexOf("/");
91+
if (slashIndex > 0) {
92+
hostsString = remaining.substring(0, slashIndex);
93+
remaining = remaining.substring(slashIndex);
94+
} else if (slashIndex == 0) {
95+
// No hosts before slash
96+
throw new IllegalArgumentException(
97+
"Sentinel hosts cannot be empty. URL must contain at least one host:port pair.");
98+
} else {
99+
// No path - everything is hosts
100+
hostsString = remaining;
101+
remaining = "";
102+
}
103+
104+
if (hostsString.trim().isEmpty()) {
105+
throw new IllegalArgumentException(
106+
"Sentinel hosts cannot be empty. URL must contain at least one host:port pair.");
107+
}
108+
109+
// Parse sentinel hosts (comma-separated)
110+
List<HostPort> sentinelHosts = parseSentinelHosts(hostsString);
111+
112+
// Parse path for service name and database
113+
String serviceName = "mymaster"; // default
114+
Integer database = null;
115+
116+
if (!remaining.isEmpty() && !remaining.equals("/")) {
117+
// Remove leading slash
118+
String path = remaining.substring(1);
119+
String[] pathParts = path.split("/");
120+
121+
if (pathParts.length > 0 && !pathParts[0].isEmpty()) {
122+
serviceName = pathParts[0];
123+
}
124+
125+
if (pathParts.length > 1 && !pathParts[1].isEmpty()) {
126+
try {
127+
database = Integer.parseInt(pathParts[1]);
128+
} catch (NumberFormatException e) {
129+
throw new IllegalArgumentException("Invalid database number: " + pathParts[1], e);
130+
}
131+
}
132+
}
133+
134+
return SentinelConfig.builder()
135+
.sentinelHosts(sentinelHosts)
136+
.serviceName(serviceName)
137+
.database(database)
138+
.username(username)
139+
.password(password)
140+
.build();
141+
142+
} catch (IllegalArgumentException e) {
143+
throw e;
144+
} catch (Exception e) {
145+
throw new IllegalArgumentException("Failed to parse Sentinel URL: " + url, e);
146+
}
147+
}
148+
149+
/**
150+
* Parse comma-separated sentinel hosts into HostPort list.
151+
*
152+
* <p>Supports formats: - host:port - host (uses default port 26379) - [ipv6]:port - [ipv6] (uses
153+
* default port 26379)
154+
*
155+
* @param hostsString Comma-separated host:port pairs
156+
* @return List of HostPort objects
157+
*/
158+
private static List<HostPort> parseSentinelHosts(String hostsString) {
159+
List<HostPort> hosts = new ArrayList<>();
160+
String[] hostParts = hostsString.split(",");
161+
162+
for (String hostPart : hostParts) {
163+
hostPart = hostPart.trim();
164+
if (hostPart.isEmpty()) {
165+
continue;
166+
}
167+
168+
hosts.add(parseHostPort(hostPart));
169+
}
170+
171+
if (hosts.isEmpty()) {
172+
throw new IllegalArgumentException(
173+
"Sentinel hosts cannot be empty. URL must contain at least one host:port pair.");
174+
}
175+
176+
return hosts;
177+
}
178+
179+
/**
180+
* Parse a single host:port pair.
181+
*
182+
* <p>Handles IPv6 addresses in brackets: [::1]:26379
183+
*
184+
* @param hostPort Host and optional port
185+
* @return HostPort object
186+
*/
187+
private static HostPort parseHostPort(String hostPort) {
188+
String host;
189+
int port = 26379; // default Sentinel port
190+
191+
// Handle IPv6: [::1]:26379 or [::1]
192+
if (hostPort.startsWith("[")) {
193+
int closeBracket = hostPort.indexOf("]");
194+
if (closeBracket == -1) {
195+
throw new IllegalArgumentException("Invalid IPv6 address format: " + hostPort);
196+
}
197+
host = hostPort.substring(1, closeBracket);
198+
199+
// Check for port after bracket
200+
if (closeBracket + 1 < hostPort.length()) {
201+
if (hostPort.charAt(closeBracket + 1) == ':') {
202+
try {
203+
port = Integer.parseInt(hostPort.substring(closeBracket + 2));
204+
} catch (NumberFormatException e) {
205+
throw new IllegalArgumentException("Invalid port number in: " + hostPort, e);
206+
}
207+
}
208+
}
209+
} else {
210+
// Handle regular host:port or just host
211+
int colonIndex = hostPort.lastIndexOf(":");
212+
if (colonIndex > 0) {
213+
host = hostPort.substring(0, colonIndex);
214+
try {
215+
port = Integer.parseInt(hostPort.substring(colonIndex + 1));
216+
} catch (NumberFormatException e) {
217+
throw new IllegalArgumentException("Invalid port number in: " + hostPort, e);
218+
}
219+
} else {
220+
host = hostPort;
221+
}
222+
}
223+
224+
return new HostPort(host, port);
225+
}
226+
227+
/** Represents a host:port pair for Sentinel nodes */
228+
@Getter
229+
public static final class HostPort {
230+
private final String host;
231+
private final int port;
232+
233+
public HostPort(String host, int port) {
234+
if (host == null || host.trim().isEmpty()) {
235+
throw new IllegalArgumentException("Host cannot be null or empty");
236+
}
237+
if (port <= 0 || port > 65535) {
238+
throw new IllegalArgumentException("Port must be between 1 and 65535, got: " + port);
239+
}
240+
this.host = host.trim();
241+
this.port = port;
242+
}
243+
}
244+
}

0 commit comments

Comments
 (0)