Skip to content

Fixing sentinel command execution to allow returning of actual responses when meaningful - behaviour controlled by 'return_responses' argument. #3191

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Jun 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2746dcd
Fixing sentinel command response
mahigupta Mar 23, 2024
c2d207b
Linter check pass
mahigupta Mar 23, 2024
bdc0a5f
Merge branch 'master' into issue/3139
mahigupta Mar 28, 2024
596458f
Merge branch 'master' into issue/3139
mahigupta May 6, 2024
87484ad
Merge branch 'master' into issue/3139
mahigupta May 8, 2024
659da06
Merge branch 'master' into issue/3139
mahigupta May 10, 2024
0b67b59
Merge branch 'master' into issue/3139
mahigupta May 16, 2024
e31cb83
Merge branch 'master' into issue/3139
mahigupta May 21, 2024
09e6ea3
Merge branch 'master' into issue/3139
mahigupta May 26, 2024
0dcc41a
Merge branch 'master' into issue/3139
mahigupta Jun 1, 2024
8fab22d
Merge branch 'master' into issue/3139
mahigupta Aug 9, 2024
de33f88
Merge branch 'master' into issue/3139
mahigupta Aug 11, 2024
3f92109
Merge branch 'master' into issue/3139
mahigupta Sep 17, 2024
f2ffe1d
Merge branch 'master' into issue/3139
petyaslavova May 14, 2025
1ef7f34
Applying review comments and fixing some issues.
petyaslavova May 15, 2025
5d7fd79
Fix several spelling mistakes
petyaslavova May 15, 2025
89aba81
Adding new sentinel integration tests. Fixing the boolean return valu…
petyaslavova May 16, 2025
638b8fe
Merge branch 'master' into issue/3139
petyaslavova May 27, 2025
e322a69
Merge branch 'master' into issue/3139
petyaslavova May 28, 2025
3110e57
Merge branch 'master' into issue/3139
petyaslavova Jun 2, 2025
d4ddc17
Replacing usage of reduce in the boolean return types with python's b…
petyaslavova Jun 5, 2025
4c88b66
Changing the default behavior to be as before, returning of the respo…
petyaslavova Jun 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 23 additions & 11 deletions redis/asyncio/sentinel.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,19 +223,31 @@ async def execute_command(self, *args, **kwargs):
once - If set to True, then execute the resulting command on a single
node at random, rather than across the entire sentinel cluster.
"""
once = bool(kwargs.get("once", False))
if "once" in kwargs.keys():
kwargs.pop("once")
once = bool(kwargs.pop("once", False))

# Check if command is supposed to return the original
# responses instead of boolean value.
return_responses = bool(kwargs.pop("return_responses", False))

if once:
await random.choice(self.sentinels).execute_command(*args, **kwargs)
else:
tasks = [
asyncio.Task(sentinel.execute_command(*args, **kwargs))
for sentinel in self.sentinels
]
await asyncio.gather(*tasks)
return True
response = await random.choice(self.sentinels).execute_command(
*args, **kwargs
)
if return_responses:
return [response]
else:
return True if response else False

tasks = [
asyncio.Task(sentinel.execute_command(*args, **kwargs))
for sentinel in self.sentinels
]
responses = await asyncio.gather(*tasks)

if return_responses:
return responses

return all(responses)

def __repr__(self):
sentinel_addresses = []
Expand Down
54 changes: 42 additions & 12 deletions redis/commands/sentinel.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,35 @@ def sentinel(self, *args):
"""Redis Sentinel's SENTINEL command."""
warnings.warn(DeprecationWarning("Use the individual sentinel_* methods"))

def sentinel_get_master_addr_by_name(self, service_name):
"""Returns a (host, port) pair for the given ``service_name``"""
return self.execute_command("SENTINEL GET-MASTER-ADDR-BY-NAME", service_name)

def sentinel_master(self, service_name):
"""Returns a dictionary containing the specified masters state."""
return self.execute_command("SENTINEL MASTER", service_name)
def sentinel_get_master_addr_by_name(self, service_name, return_responses=False):
"""
Returns a (host, port) pair for the given ``service_name`` when return_responses is True,
otherwise returns a boolean value that indicates if the command was successful.
"""
return self.execute_command(
"SENTINEL GET-MASTER-ADDR-BY-NAME",
service_name,
once=True,
return_responses=return_responses,
)

def sentinel_master(self, service_name, return_responses=False):
"""
Returns a dictionary containing the specified masters state, when return_responses is True,
otherwise returns a boolean value that indicates if the command was successful.
"""
return self.execute_command(
"SENTINEL MASTER", service_name, return_responses=return_responses
)

def sentinel_masters(self):
"""Returns a list of dictionaries containing each master's state."""
"""
Returns a list of dictionaries containing each master's state.

Important: This function is called by the Sentinel implementation and is
called directly on the Redis standalone client for sentinels,
so it doesn't support the "once" and "return_responses" options.
"""
return self.execute_command("SENTINEL MASTERS")

def sentinel_monitor(self, name, ip, port, quorum):
Expand All @@ -31,16 +50,27 @@ def sentinel_remove(self, name):
"""Remove a master from Sentinel's monitoring"""
return self.execute_command("SENTINEL REMOVE", name)

def sentinel_sentinels(self, service_name):
"""Returns a list of sentinels for ``service_name``"""
return self.execute_command("SENTINEL SENTINELS", service_name)
def sentinel_sentinels(self, service_name, return_responses=False):
"""
Returns a list of sentinels for ``service_name``, when return_responses is True,
otherwise returns a boolean value that indicates if the command was successful.
"""
return self.execute_command(
"SENTINEL SENTINELS", service_name, return_responses=return_responses
)

def sentinel_set(self, name, option, value):
"""Set Sentinel monitoring parameters for a given master"""
return self.execute_command("SENTINEL SET", name, option, value)

def sentinel_slaves(self, service_name):
"""Returns a list of slaves for ``service_name``"""
"""
Returns a list of slaves for ``service_name``

Important: This function is called by the Sentinel implementation and is
called directly on the Redis standalone client for sentinels,
so it doesn't support the "once" and "return_responses" options.
"""
return self.execute_command("SENTINEL SLAVES", service_name)

def sentinel_reset(self, pattern):
Expand Down
27 changes: 19 additions & 8 deletions redis/sentinel.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,16 +254,27 @@ def execute_command(self, *args, **kwargs):
once - If set to True, then execute the resulting command on a single
node at random, rather than across the entire sentinel cluster.
"""
once = bool(kwargs.get("once", False))
if "once" in kwargs.keys():
kwargs.pop("once")
once = bool(kwargs.pop("once", False))

# Check if command is supposed to return the original
# responses instead of boolean value.
return_responses = bool(kwargs.pop("return_responses", False))

if once:
random.choice(self.sentinels).execute_command(*args, **kwargs)
else:
for sentinel in self.sentinels:
sentinel.execute_command(*args, **kwargs)
return True
response = random.choice(self.sentinels).execute_command(*args, **kwargs)
if return_responses:
return [response]
else:
return True if response else False

responses = []
for sentinel in self.sentinels:
responses.append(sentinel.execute_command(*args, **kwargs))

if return_responses:
return responses

return all(responses)

def __repr__(self):
sentinel_addresses = []
Expand Down
89 changes: 84 additions & 5 deletions tests/test_asyncio/test_sentinel.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,35 @@ def sentinel(request, cluster):
return Sentinel([("foo", 26379), ("bar", 26379)])


@pytest.fixture()
async def deployed_sentinel(request):
sentinel_ips = request.config.getoption("--sentinels")
sentinel_endpoints = [
(ip.strip(), int(port.strip()))
for ip, port in (endpoint.split(":") for endpoint in sentinel_ips.split(","))
]
kwargs = {}
decode_responses = True

sentinel_kwargs = {"decode_responses": decode_responses}
force_master_ip = "localhost"

protocol = request.config.getoption("--protocol", 2)

sentinel = Sentinel(
sentinel_endpoints,
force_master_ip=force_master_ip,
sentinel_kwargs=sentinel_kwargs,
socket_timeout=0.1,
protocol=protocol,
decode_responses=decode_responses,
**kwargs,
)
yield sentinel
for s in sentinel.sentinels:
await s.close()


@pytest.mark.onlynoncluster
async def test_discover_master(sentinel, master_ip):
address = await sentinel.discover_master("mymaster")
Expand Down Expand Up @@ -226,19 +255,22 @@ async def test_slave_round_robin(cluster, sentinel, master_ip):


@pytest.mark.onlynoncluster
async def test_ckquorum(cluster, sentinel):
assert await sentinel.sentinel_ckquorum("mymaster")
async def test_ckquorum(sentinel):
resp = await sentinel.sentinel_ckquorum("mymaster")
assert resp is True


@pytest.mark.onlynoncluster
async def test_flushconfig(cluster, sentinel):
assert await sentinel.sentinel_flushconfig()
async def test_flushconfig(sentinel):
resp = await sentinel.sentinel_flushconfig()
assert resp is True


@pytest.mark.onlynoncluster
async def test_reset(cluster, sentinel):
cluster.master["is_odown"] = True
assert await sentinel.sentinel_reset("mymaster")
resp = await sentinel.sentinel_reset("mymaster")
assert resp is True


@pytest.mark.onlynoncluster
Expand Down Expand Up @@ -284,3 +316,50 @@ async def test_repr_correctly_represents_connection_object(sentinel):
str(connection)
== "<redis.asyncio.sentinel.SentinelManagedConnection,host=127.0.0.1,port=6379)>" # noqa: E501
)


# Tests against real sentinel instances
@pytest.mark.onlynoncluster
async def test_get_sentinels(deployed_sentinel):
resps = await deployed_sentinel.sentinel_sentinels(
"redis-py-test", return_responses=True
)

# validate that the original command response is returned
assert isinstance(resps, list)

# validate that the command has been executed against all sentinels
# each response from each sentinel is returned
assert len(resps) > 1

# validate default behavior
resps = await deployed_sentinel.sentinel_sentinels("redis-py-test")
assert isinstance(resps, bool)


@pytest.mark.onlynoncluster
async def test_get_master_addr_by_name(deployed_sentinel):
resps = await deployed_sentinel.sentinel_get_master_addr_by_name(
"redis-py-test",
return_responses=True,
)

# validate that the original command response is returned
assert isinstance(resps, list)

# validate that the command has been executed just once
# when executed once, only one response element is returned
assert len(resps) == 1

assert isinstance(resps[0], tuple)

# validate default behavior
resps = await deployed_sentinel.sentinel_get_master_addr_by_name("redis-py-test")
assert isinstance(resps, bool)


@pytest.mark.onlynoncluster
async def test_redis_master_usage(deployed_sentinel):
r = await deployed_sentinel.master_for("redis-py-test", db=0)
await r.set("foo", "bar")
assert (await r.get("foo")) == "bar"
88 changes: 82 additions & 6 deletions tests/test_sentinel.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,35 @@ def sentinel(request, cluster):
return Sentinel([("foo", 26379), ("bar", 26379)])


@pytest.fixture()
def deployed_sentinel(request):
sentinel_ips = request.config.getoption("--sentinels")
sentinel_endpoints = [
(ip.strip(), int(port.strip()))
for ip, port in (endpoint.split(":") for endpoint in sentinel_ips.split(","))
]
kwargs = {}
decode_responses = True

sentinel_kwargs = {"decode_responses": decode_responses}
force_master_ip = "localhost"

protocol = request.config.getoption("--protocol", 2)

sentinel = Sentinel(
sentinel_endpoints,
force_master_ip=force_master_ip,
sentinel_kwargs=sentinel_kwargs,
socket_timeout=0.1,
protocol=protocol,
decode_responses=decode_responses,
**kwargs,
)
yield sentinel
for s in sentinel.sentinels:
s.close()


@pytest.mark.onlynoncluster
def test_discover_master(sentinel, master_ip):
address = sentinel.discover_master("mymaster")
Expand Down Expand Up @@ -184,7 +213,7 @@ def test_discover_slaves(cluster, sentinel):


@pytest.mark.onlynoncluster
def test_master_for(cluster, sentinel, master_ip):
def test_master_for(sentinel, master_ip):
master = sentinel.master_for("mymaster", db=9)
assert master.ping()
assert master.connection_pool.master_address == (master_ip, 6379)
Expand Down Expand Up @@ -228,19 +257,22 @@ def test_slave_round_robin(cluster, sentinel, master_ip):


@pytest.mark.onlynoncluster
def test_ckquorum(cluster, sentinel):
assert sentinel.sentinel_ckquorum("mymaster")
def test_ckquorum(sentinel):
resp = sentinel.sentinel_ckquorum("mymaster")
assert resp is True


@pytest.mark.onlynoncluster
def test_flushconfig(cluster, sentinel):
assert sentinel.sentinel_flushconfig()
def test_flushconfig(sentinel):
resp = sentinel.sentinel_flushconfig()
assert resp is True


@pytest.mark.onlynoncluster
def test_reset(cluster, sentinel):
cluster.master["is_odown"] = True
assert sentinel.sentinel_reset("mymaster")
resp = sentinel.sentinel_reset("mymaster")
assert resp is True


@pytest.mark.onlynoncluster
Expand All @@ -266,3 +298,47 @@ def mock_disconnect():

assert calls == 1
pool.disconnect()


# Tests against real sentinel instances
@pytest.mark.onlynoncluster
def test_get_sentinels(deployed_sentinel):
resps = deployed_sentinel.sentinel_sentinels("redis-py-test", return_responses=True)

# validate that the original command response is returned
assert isinstance(resps, list)

# validate that the command has been executed against all sentinels
# each response from each sentinel is returned
assert len(resps) > 1

# validate default behavior
resps = deployed_sentinel.sentinel_sentinels("redis-py-test")
assert isinstance(resps, bool)


@pytest.mark.onlynoncluster
def test_get_master_addr_by_name(deployed_sentinel):
resps = deployed_sentinel.sentinel_get_master_addr_by_name(
"redis-py-test", return_responses=True
)

# validate that the original command response is returned
assert isinstance(resps, list)

# validate that the command has been executed just once
# when executed once, only one response element is returned
assert len(resps) == 1

assert isinstance(resps[0], tuple)

# validate default behavior
resps = deployed_sentinel.sentinel_get_master_addr_by_name("redis-py-test")
assert isinstance(resps, bool)


@pytest.mark.onlynoncluster
def test_redis_master_usage(deployed_sentinel):
r = deployed_sentinel.master_for("redis-py-test", db=0)
r.set("foo", "bar")
assert r.get("foo") == "bar"
Loading