From 27c93caf675feba551676b3aac4879ac4b6ab2aa Mon Sep 17 00:00:00 2001 From: Petya Slavova Date: Fri, 15 May 2026 13:28:50 +0300 Subject: [PATCH 1/5] Adding support for new INCREX command --- .github/workflows/integration.yaml | 2 +- redis/commands/core.py | 179 +++++++++++++++++++++++++++- tests/test_asyncio/test_commands.py | 101 ++++++++++++++++ tests/test_commands.py | 91 ++++++++++++++ 4 files changed, 366 insertions(+), 7 deletions(-) diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index ec2e87a6c3..db6fa787a6 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -31,7 +31,7 @@ env: # for example after 8.2.1 is published, 8.2 image contains 8.2.1 content CURRENT_REDIS_VERSION: '8.6.1' REDIS_VERSION_CUSTOM_MAP: >- - 8.8:unstable-24805570909-debian + 8.8:8.8-rc1 jobs: dependency-audit: diff --git a/redis/commands/core.py b/redis/commands/core.py index 783ddfdb57..2e9364673f 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -3264,7 +3264,15 @@ def getex( For more information, see https://redis.io/commands/getex """ - if not at_most_one_value_set((ex, px, exat, pxat, persist)): + if not at_most_one_value_set( + ( + ex is not None, + px is not None, + exat is not None, + pxat is not None, + persist, + ) + ): raise DataError( "``ex``, ``px``, ``exat``, ``pxat``, " "and ``persist`` are mutually exclusive." @@ -3386,6 +3394,124 @@ def incrbyfloat(self, name: KeyT, amount: float = 1.0) -> float | Awaitable[floa """ return self.execute_command("INCRBYFLOAT", name, amount) + @overload + def increx( + self: SyncClientProtocol, + name: KeyT, + *, + byfloat: EncodableT | None = None, + byint: EncodableT | None = None, + lbound: EncodableT | None = None, + ubound: EncodableT | None = None, + overflow: Literal["FAIL", "REJECT", "SAT"] | None = None, + ex: ExpiryT | None = None, + px: ExpiryT | None = None, + exat: AbsExpiryT | None = None, + pxat: AbsExpiryT | None = None, + persist: bool = False, + enx: bool = False, + ) -> list[Number | bytes | str]: ... + + @overload + def increx( + self: AsyncClientProtocol, + name: KeyT, + *, + byfloat: EncodableT | None = None, + byint: EncodableT | None = None, + lbound: EncodableT | None = None, + ubound: EncodableT | None = None, + overflow: Literal["FAIL", "REJECT", "SAT"] | None = None, + ex: ExpiryT | None = None, + px: ExpiryT | None = None, + exat: AbsExpiryT | None = None, + pxat: AbsExpiryT | None = None, + persist: bool = False, + enx: bool = False, + ) -> Awaitable[list[Number | bytes | str]]: ... + + def increx( + self, + name: KeyT, + *, + byfloat: EncodableT | None = None, + byint: EncodableT | None = None, + lbound: EncodableT | None = None, + ubound: EncodableT | None = None, + overflow: Literal["FAIL", "REJECT", "SAT"] | None = None, + ex: ExpiryT | None = None, + px: ExpiryT | None = None, + exat: AbsExpiryT | None = None, + pxat: AbsExpiryT | None = None, + persist: bool = False, + enx: bool = False, + ) -> list[Number | bytes | str] | Awaitable[list[Number | bytes | str]]: + """ + Increment the numeric value at key ``name`` and return the new value + and the actual increment. + + ``byfloat`` increments a floating point value by the provided amount. + + ``byint`` increments an integer value by the provided amount. + + If neither ``byfloat`` nor ``byint`` is specified, the value is + incremented by one. + + ``lbound`` and ``ubound`` constrain the valid range of the result. + + ``overflow`` controls bound handling and can be ``FAIL``, ``REJECT``, + or ``SAT``. + + ``enx`` applies the expiration only when the key does not already + have an expiration, and requires ``ex``, ``px``, ``exat``, or ``pxat``. + """ + if byfloat is not None and byint is not None: + raise DataError("``byfloat`` and ``byint`` are mutually exclusive.") + + if not at_most_one_value_set( + ( + ex is not None, + px is not None, + exat is not None, + pxat is not None, + persist, + ) + ): + raise DataError( + "``ex``, ``px``, ``exat``, ``pxat``, " + "and ``persist`` are mutually exclusive." + ) + + if overflow is not None and overflow not in {"FAIL", "REJECT", "SAT"}: + raise DataError("INCREX overflow must be one of: FAIL, REJECT, SAT") + + if enx and ex is None and px is None and exat is None and pxat is None: + raise DataError( + "``enx`` requires one of ``ex``, ``px``, ``exat``, or ``pxat``." + ) + + pieces: list[EncodableT] = [name] + + if byfloat is not None: + pieces.extend(("BYFLOAT", byfloat)) + elif byint is not None: + pieces.extend(("BYINT", byint)) + + if lbound is not None: + pieces.extend(("LBOUND", lbound)) + if ubound is not None: + pieces.extend(("UBOUND", ubound)) + if overflow is not None: + pieces.extend(("OVERFLOW", overflow)) + + pieces.extend(extract_expire_flags(ex, px, exat, pxat)) + if persist: + pieces.append("PERSIST") + if enx: + pieces.append("ENX") + + return self.execute_command("INCREX", *pieces) + @overload def keys( self: SyncClientProtocol, pattern: PatternT = "*", **kwargs @@ -3598,7 +3724,15 @@ def msetex( Available since Redis 8.4 For more information, see https://redis.io/commands/msetex """ - if not at_most_one_value_set((ex, px, exat, pxat, keepttl)): + if not at_most_one_value_set( + ( + ex is not None, + px is not None, + exat is not None, + pxat is not None, + keepttl, + ) + ): raise DataError( "``ex``, ``px``, ``exat``, ``pxat``, " "and ``keepttl`` are mutually exclusive." @@ -4117,14 +4251,31 @@ def set( For more information, see https://redis.io/commands/set """ - if not at_most_one_value_set((ex, px, exat, pxat, keepttl)): + if not at_most_one_value_set( + ( + ex is not None, + px is not None, + exat is not None, + pxat is not None, + keepttl, + ) + ): raise DataError( "``ex``, ``px``, ``exat``, ``pxat``, " "and ``keepttl`` are mutually exclusive." ) # Enforce mutual exclusivity among all conditional switches. - if not at_most_one_value_set((nx, xx, ifeq, ifne, ifdeq, ifdne)): + if not at_most_one_value_set( + ( + nx, + xx, + ifeq is not None, + ifne is not None, + ifdeq is not None, + ifdne is not None, + ) + ): raise DataError( "``nx``, ``xx``, ``ifeq``, ``ifne``, ``ifdeq``, ``ifdne`` are mutually exclusive." ) @@ -8891,7 +9042,15 @@ def hgetex( if not keys: raise DataError("'hgetex' should have at least one key provided") - if not at_most_one_value_set((ex, px, exat, pxat, persist)): + if not at_most_one_value_set( + ( + ex is not None, + px is not None, + exat is not None, + pxat is not None, + persist, + ) + ): raise DataError( "``ex``, ``px``, ``exat``, ``pxat``, " "and ``persist`` are mutually exclusive." @@ -9118,7 +9277,15 @@ def hsetex( "'items' must contain a list of key/value pairs." ) - if not at_most_one_value_set((ex, px, exat, pxat, keepttl)): + if not at_most_one_value_set( + ( + ex is not None, + px is not None, + exat is not None, + pxat is not None, + keepttl, + ) + ): raise DataError( "``ex``, ``px``, ``exat``, ``pxat``, " "and ``keepttl`` are mutually exclusive." diff --git a/tests/test_asyncio/test_commands.py b/tests/test_asyncio/test_commands.py index 3bf22dff46..223f0b64c7 100644 --- a/tests/test_asyncio/test_commands.py +++ b/tests/test_asyncio/test_commands.py @@ -1643,6 +1643,12 @@ async def test_get_and_set(self, r: redis.Redis): assert await r.get("integer") == str(integer).encode() assert (await r.get("unicode_string")).decode("utf-8") == unicode_string + async def test_getex_zero_expiry_options_are_mutually_exclusive( + self, r: redis.Redis + ): + with pytest.raises(DataError): + await r.getex("a", ex=0, px=1) + async def test_get_set_bit(self, r: redis.Redis): # no value assert not await r.getbit("a", 5) @@ -1690,6 +1696,75 @@ async def test_incrbyfloat(self, r: redis.Redis): assert await r.incrbyfloat("a", 1.1) == 2.1 assert float(await r.get("a")) == float(2.1) + @skip_if_server_version_lt("8.7.0") + async def test_increx_default(self, r: redis.Redis): + key = "increx:default" + assert await r.set(key, 10) + assert await r.increx(key) == [11, 1] + assert await r.get(key) == b"11" + + @skip_if_server_version_lt("8.7.0") + async def test_increx_byint(self, r: redis.Redis): + key = "increx:byint" + assert await r.set(key, 20) + assert await r.increx(key, byint=4) == [24, 4] + assert await r.get(key) == b"24" + + @skip_if_server_version_lt("8.7.0") + async def test_increx_byfloat(self, r: redis.Redis): + key = "increx:float" + assert await r.set(key, "1.5") + result = await r.increx(key, byfloat=0.25, lbound=0, ubound=2) + assert [float(value) for value in result] == pytest.approx([1.75, 0.25]) + assert float(await r.get(key)) == pytest.approx(1.75) + + @skip_if_server_version_lt("8.7.0") + async def test_increx_saturating_bounds(self, r: redis.Redis): + key = "increx:sat" + assert await r.set(key, "1.8") + result = await r.increx(key, byfloat=0.7, ubound=2, overflow="SAT") + assert [float(value) for value in result] == pytest.approx([2.0, 0.2]) + assert float(await r.get(key)) == pytest.approx(2.0) + + @skip_if_server_version_lt("8.7.0") + async def test_increx_fail_overflow_keeps_value(self, r: redis.Redis): + key = "increx:overflow" + assert await r.set(key, 10) + with pytest.raises(ResponseError): + await r.increx(key, byint=5, ubound=12) + assert await r.get(key) == b"10" + + @skip_if_server_version_lt("8.7.0") + async def test_increx_expiration_enx(self, r: redis.Redis): + key = "increx:expiration" + assert await r.set(key, 40) + assert await r.ttl(key) == -1 + + assert await r.increx(key, byint=2, ex=60, enx=True) == [42, 2] + ttl = await r.ttl(key) + assert 0 < ttl <= 60 + + assert await r.increx(key, byint=3, ex=600, enx=True) == [45, 3] + assert 0 < await r.ttl(key) <= ttl + + async def test_increx_invalid_options(self, r: redis.Redis): + key = "increx:invalid" + assert await r.set(key, 1) + with pytest.raises(DataError): + await r.increx(key, byfloat=1.0, byint=1) + with pytest.raises(DataError): + await r.increx(key, ex=10, px=10) + with pytest.raises(DataError): + await r.increx(key, overflow="WRAP") + with pytest.raises(DataError): + await r.increx(key, enx=True) + + async def test_increx_zero_expiry_options_are_mutually_exclusive( + self, r: redis.Redis + ): + with pytest.raises(DataError): + await r.increx("a", ex=0, px=1) + @pytest.mark.onlynoncluster async def test_keys(self, r: redis.Redis): assert await r.keys() == [] @@ -2059,6 +2134,12 @@ async def test_msetex_invalid_inputs(self, r): with pytest.raises(exceptions.DataError): await r.msetex(mapping, ex=10, keepttl=True) + async def test_msetex_zero_expiry_options_are_mutually_exclusive( + self, r: redis.Redis + ): + with pytest.raises(DataError): + await r.msetex({"a": 1}, ex=0, px=1) + @pytest.mark.onlynoncluster async def test_msetnx(self, r: redis.Redis): d = {"a": b"1", "b": b"2", "c": b"3"} @@ -2193,6 +2274,10 @@ async def test_set_ex(self, r: redis.Redis): assert await r.set("a", "1", ex=10) assert 0 < await r.ttl("a") <= 10 + async def test_set_zero_expiry_options_are_mutually_exclusive(self, r: redis.Redis): + with pytest.raises(DataError): + await r.set("a", "1", ex=0, px=1) + @skip_if_server_version_lt("2.6.0") async def test_set_ex_timedelta(self, r: redis.Redis): expire_at = datetime.timedelta(seconds=60) @@ -2214,6 +2299,10 @@ async def test_set_keepttl(self, r: redis.Redis): assert await r.get("a") == b"2" assert 0 < await r.ttl("a") <= 10 + async def test_set_empty_condition_is_mutually_exclusive(self, r: redis.Redis): + with pytest.raises(DataError): + await r.set("a", "1", nx=True, ifeq=b"") + @skip_if_server_version_lt("8.3.224") async def test_set_ifeq_true_sets_and_returns_true(self, r): await r.delete("k") @@ -3429,6 +3518,18 @@ async def test_hget_and_hset(self, r: redis.Redis): assert await r.hset("a", 0, 10) == 1 assert await r.hset("a", "", 10) == 1 + async def test_hgetex_zero_expiry_options_are_mutually_exclusive( + self, r: redis.Redis + ): + with pytest.raises(DataError): + await r.hgetex("h", "f", ex=0, px=1) + + async def test_hsetex_zero_expiry_options_are_mutually_exclusive( + self, r: redis.Redis + ): + with pytest.raises(DataError): + await r.hsetex("h", "f", "v", ex=0, px=1) + async def test_hset_with_multi_key_values(self, r: redis.Redis): await r.hset("a", mapping={"1": 1, "2": 2, "3": 3}) assert await r.hget("a", "1") == b"1" diff --git a/tests/test_commands.py b/tests/test_commands.py index 882b8bdb3e..e85e472f93 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -2214,6 +2214,10 @@ def test_getex(self, r): assert r.getex("a", persist=True) == b"1" assert r.ttl("a") == -1 + def test_getex_zero_expiry_options_are_mutually_exclusive(self, r): + with pytest.raises(DataError): + r.getex("a", ex=0, px=1) + def test_getitem_and_setitem(self, r): r["a"] = "bar" assert r["a"] == b"bar" @@ -2273,6 +2277,73 @@ def test_incrbyfloat(self, r): assert r.incrbyfloat("a", 1.1) == 2.1 assert float(r["a"]) == float(2.1) + @skip_if_server_version_lt("8.7.0") + def test_increx_default(self, r): + key = "increx:default" + assert r.set(key, 10) + assert r.increx(key) == [11, 1] + assert r[key] == b"11" + + @skip_if_server_version_lt("8.7.0") + def test_increx_byint(self, r): + key = "increx:byint" + assert r.set(key, 20) + assert r.increx(key, byint=4) == [24, 4] + assert r[key] == b"24" + + @skip_if_server_version_lt("8.7.0") + def test_increx_byfloat(self, r): + key = "increx:float" + assert r.set(key, "1.5") + result = r.increx(key, byfloat=0.25, lbound=0, ubound=2) + assert [float(value) for value in result] == pytest.approx([1.75, 0.25]) + assert float(r[key]) == pytest.approx(1.75) + + @skip_if_server_version_lt("8.7.0") + def test_increx_saturating_bounds(self, r): + key = "increx:sat" + assert r.set(key, "1.8") + result = r.increx(key, byfloat=0.7, ubound=2, overflow="SAT") + assert [float(value) for value in result] == pytest.approx([2.0, 0.2]) + assert float(r[key]) == pytest.approx(2.0) + + @skip_if_server_version_lt("8.7.0") + def test_increx_fail_overflow_keeps_value(self, r): + key = "increx:overflow" + assert r.set(key, 10) + with pytest.raises(ResponseError): + r.increx(key, byint=5, ubound=12) + assert r[key] == b"10" + + @skip_if_server_version_lt("8.7.0") + def test_increx_expiration_enx(self, r): + key = "increx:expiration" + assert r.set(key, 40) + assert r.ttl(key) == -1 + + assert r.increx(key, byint=2, ex=60, enx=True) == [42, 2] + ttl = r.ttl(key) + assert 0 < ttl <= 60 + + assert r.increx(key, byint=3, ex=600, enx=True) == [45, 3] + assert 0 < r.ttl(key) <= ttl + + def test_increx_invalid_options(self, r): + key = "increx:invalid" + assert r.set(key, 1) + with pytest.raises(DataError): + r.increx(key, byfloat=1.0, byint=1) + with pytest.raises(DataError): + r.increx(key, ex=10, px=10) + with pytest.raises(DataError): + r.increx(key, overflow="WRAP") + with pytest.raises(DataError): + r.increx(key, enx=True) + + def test_increx_zero_expiry_options_are_mutually_exclusive(self, r): + with pytest.raises(DataError): + r.increx("a", ex=0, px=1) + @pytest.mark.onlynoncluster def test_keys(self, r): assert r.keys() == [] @@ -2644,6 +2715,10 @@ def test_msetex_invalid_inputs(self, r): with pytest.raises(exceptions.DataError): r.msetex(mapping, ex=10, keepttl=True) + def test_msetex_zero_expiry_options_are_mutually_exclusive(self, r): + with pytest.raises(DataError): + r.msetex({"a": 1}, ex=0, px=1) + @pytest.mark.onlynoncluster def test_msetnx(self, r): d = {"a": b"1", "b": b"2", "c": b"3"} @@ -2850,6 +2925,10 @@ def test_set_ex(self, r): with pytest.raises(exceptions.DataError): assert r.set("a", "1", ex=10.0) + def test_set_zero_expiry_options_are_mutually_exclusive(self, r): + with pytest.raises(DataError): + r.set("a", "1", ex=0, px=1) + @skip_if_server_version_lt("2.6.0") def test_set_ex_str(self, r): assert r.set("a", "1", ex="10") @@ -2890,6 +2969,10 @@ def test_set_keepttl(self, r): assert r.get("a") == b"2" assert 0 < r.ttl("a") <= 10 + def test_set_empty_condition_is_mutually_exclusive(self, r): + with pytest.raises(DataError): + r.set("a", "1", nx=True, ifeq=b"") + @skip_if_server_version_lt("8.3.224") def test_set_ifeq_true_sets_and_returns_true(self, r): r.delete("k") @@ -4665,6 +4748,14 @@ def test_hget_and_hset(self, r): assert r.hset("a", 0, 10) == 1 assert r.hset("a", "", 10) == 1 + def test_hgetex_zero_expiry_options_are_mutually_exclusive(self, r): + with pytest.raises(DataError): + r.hgetex("h", "f", ex=0, px=1) + + def test_hsetex_zero_expiry_options_are_mutually_exclusive(self, r): + with pytest.raises(DataError): + r.hsetex("h", "f", "v", ex=0, px=1) + def test_hset_with_multi_key_values(self, r): r.hset("a", mapping={"1": 1, "2": 2, "3": 3}) assert r.hget("a", "1") == b"1" From c5b7e759c322babaa526b746ea9792bafbea112f Mon Sep 17 00:00:00 2001 From: Petya Slavova Date: Mon, 18 May 2026 11:43:04 +0300 Subject: [PATCH 2/5] Applying review comment --- redis/commands/core.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/redis/commands/core.py b/redis/commands/core.py index 7de603cc0a..38e02eacd4 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -3465,7 +3465,12 @@ def increx( ``enx`` applies the expiration only when the key does not already have an expiration, and requires ``ex``, ``px``, ``exat``, or ``pxat``. """ - if byfloat is not None and byint is not None: + if not at_most_one_value_set( + ( + byfloat is not None, + byint is not None, + ) + ): raise DataError("``byfloat`` and ``byint`` are mutually exclusive.") if not at_most_one_value_set( From b3da4839cabca823de4d46f0437a0fe2aefd294a Mon Sep 17 00:00:00 2001 From: Petya Slavova Date: Mon, 18 May 2026 11:46:38 +0300 Subject: [PATCH 3/5] Fixing linters --- tests/test_asyncio/test_commands.py | 1 + tests/test_commands.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/test_asyncio/test_commands.py b/tests/test_asyncio/test_commands.py index 113543cf62..76cbdcbdd9 100644 --- a/tests/test_asyncio/test_commands.py +++ b/tests/test_asyncio/test_commands.py @@ -3529,6 +3529,7 @@ async def test_hsetex_zero_expiry_options_are_mutually_exclusive( ): with pytest.raises(DataError): await r.hsetex("h", "f", "v", ex=0, px=1) + async def test_hget_and_hset_with_encodable_fields_and_values(self, r: redis.Redis): cases = ( (b"field-bytes", b"value-bytes", b"value-bytes"), diff --git a/tests/test_commands.py b/tests/test_commands.py index b92f49b206..631ee4e0ea 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -4755,6 +4755,7 @@ def test_hgetex_zero_expiry_options_are_mutually_exclusive(self, r): def test_hsetex_zero_expiry_options_are_mutually_exclusive(self, r): with pytest.raises(DataError): r.hsetex("h", "f", "v", ex=0, px=1) + def test_hget_and_hset_with_encodable_fields_and_values(self, r): cases = ( (b"field-bytes", b"value-bytes", b"value-bytes"), From 22ac22f909ddf0f6e632783bf09ea4b297189f16 Mon Sep 17 00:00:00 2001 From: Petya Slavova Date: Mon, 18 May 2026 15:26:22 +0300 Subject: [PATCH 4/5] Fix BGSAVE flaky tests --- tests/test_asyncio/test_cluster.py | 23 ++++++++++---- tests/test_cluster.py | 51 ++++++++++++++++++++++++++---- tests/test_commands.py | 14 +++++++- 3 files changed, 73 insertions(+), 15 deletions(-) diff --git a/tests/test_asyncio/test_cluster.py b/tests/test_asyncio/test_cluster.py index f7924509cb..57df91402e 100644 --- a/tests/test_asyncio/test_cluster.py +++ b/tests/test_asyncio/test_cluster.py @@ -1107,6 +1107,17 @@ class TestClusterRedisCommands: Tests for RedisCluster unique commands """ + async def _wait_for_bgsave(self, r: RedisCluster, timeout=10) -> None: + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while True: + info = await r.info("persistence") + if int(info.get("rdb_bgsave_in_progress", 0)) == 0: + return + if loop.time() > deadline: + pytest.fail("Timed out waiting for BGSAVE to finish") + await asyncio.sleep(0.05) + async def test_get_and_set(self, r: RedisCluster) -> None: # get and set can't be tested independently of each other assert await r.get("a") is None @@ -1536,13 +1547,11 @@ async def test_readwrite(self) -> None: @skip_if_redis_enterprise() async def test_bgsave(self, r: RedisCluster) -> None: - try: - assert await r.bgsave() - await asyncio.sleep(0.3) - assert await r.bgsave(True) - except ResponseError as e: - if "Background save already in progress" not in e.__str__(): - raise + await self._wait_for_bgsave(r) + assert await r.bgsave() + await self._wait_for_bgsave(r) + assert await r.bgsave(True) + await self._wait_for_bgsave(r) async def test_info(self, r: RedisCluster) -> None: # Map keys to same slot diff --git a/tests/test_cluster.py b/tests/test_cluster.py index 430eb2f459..914dae7bfe 100644 --- a/tests/test_cluster.py +++ b/tests/test_cluster.py @@ -7,7 +7,7 @@ from typing import List import warnings from queue import LifoQueue, Queue -from time import sleep +from time import monotonic, sleep from unittest.mock import DEFAULT, Mock, call, patch import pytest @@ -1100,6 +1100,16 @@ class TestClusterRedisCommands: Tests for RedisCluster unique commands """ + def _wait_for_bgsave(self, r, timeout=10): + deadline = monotonic() + timeout + while True: + info = r.info("persistence") + if int(info.get("rdb_bgsave_in_progress", 0)) == 0: + return + if monotonic() > deadline: + pytest.fail("Timed out waiting for BGSAVE to finish") + sleep(0.05) + def test_case_insensitive_command_names(self, r): assert ( r.cluster_response_callbacks["cluster slots"] @@ -1669,9 +1679,11 @@ def test_readwrite(self): @skip_if_redis_enterprise() def test_bgsave(self, r): + self._wait_for_bgsave(r) assert r.bgsave() - sleep(0.3) + self._wait_for_bgsave(r) assert r.bgsave(True) + self._wait_for_bgsave(r) def test_info(self, r): # Map keys to same slot @@ -3327,6 +3339,17 @@ def execute_command(*_args, **_kwargs): execute_command_mock.side_effect = execute_command errors: list[Exception] = [] + initialize_paused = threading.Event() + allow_initialize_to_finish = threading.Event() + original_check_slots_coverage = nm.check_slots_coverage + + def check_slots_coverage(slots_cache): + # Pause initialization after it has rebuilt the temporary slots + # cache, so move_slot runs during a deterministic refresh window. + initialize_paused.set() + if not allow_initialize_to_finish.wait(timeout=5): + raise TimeoutError("Timed out waiting for move_slot worker") + return original_check_slots_coverage(slots_cache) def initialize_worker(): """Reinitialize the cluster""" @@ -3349,15 +3372,29 @@ def move_slots_worker(): except Exception as e: errors.append(e) - for _ in range(100): + with patch.object(nm, "check_slots_coverage", check_slots_coverage): t1 = threading.Thread(target=initialize_worker) t2 = threading.Thread(target=move_slots_worker) t1.start() - t2.start() - - t1.join() - t2.join() + initialize_ready = False + move_worker_started = False + try: + initialize_ready = initialize_paused.wait(timeout=5) + if initialize_ready: + t2.start() + move_worker_started = True + t2.join(timeout=5) + finally: + allow_initialize_to_finish.set() + t1.join(timeout=5) + if move_worker_started and t2.is_alive(): + t2.join(timeout=5) + + assert initialize_ready + if move_worker_started: + assert not t2.is_alive() + assert not t1.is_alive() # check that no errors occurred assert len(errors) == 0, f"Errors occurred: {errors}" diff --git a/tests/test_commands.py b/tests/test_commands.py index 631ee4e0ea..dc657a54aa 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -87,6 +87,16 @@ def test_case_insensitive_command_names(self, r): class TestRedisCommands: + def _wait_for_bgsave(self, r, timeout=10): + deadline = time.monotonic() + timeout + while True: + info = r.info("persistence") + if int(info.get("rdb_bgsave_in_progress", 0)) == 0: + return + if time.monotonic() > deadline: + pytest.fail("Timed out waiting for BGSAVE to finish") + time.sleep(0.05) + @pytest.mark.onlynoncluster @skip_if_redis_enterprise() def test_auth(self, r, request): @@ -1547,9 +1557,11 @@ def test_hotkeys_get_all_fields_decoded(self, decoded_r: redis.Redis): @skip_if_redis_enterprise() def test_bgsave(self, r): + self._wait_for_bgsave(r) assert r.bgsave() - time.sleep(0.3) + self._wait_for_bgsave(r) assert r.bgsave(True) + self._wait_for_bgsave(r) def test_never_decode_option(self, r: redis.Redis): opts = {NEVER_DECODE: []} From d007796a4815f91ff4cd80783b7d4e6b68521eee Mon Sep 17 00:00:00 2001 From: Petya Slavova Date: Tue, 19 May 2026 13:54:41 +0300 Subject: [PATCH 5/5] Applying review comment about flaky test fix. --- tests/test_asyncio/test_cluster.py | 2 +- tests/test_cluster.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_asyncio/test_cluster.py b/tests/test_asyncio/test_cluster.py index 57df91402e..652a45e83f 100644 --- a/tests/test_asyncio/test_cluster.py +++ b/tests/test_asyncio/test_cluster.py @@ -1111,7 +1111,7 @@ async def _wait_for_bgsave(self, r: RedisCluster, timeout=10) -> None: loop = asyncio.get_running_loop() deadline = loop.time() + timeout while True: - info = await r.info("persistence") + info = await r.info("persistence", target_nodes=r.get_default_node()) if int(info.get("rdb_bgsave_in_progress", 0)) == 0: return if loop.time() > deadline: diff --git a/tests/test_cluster.py b/tests/test_cluster.py index 914dae7bfe..0cb934687c 100644 --- a/tests/test_cluster.py +++ b/tests/test_cluster.py @@ -1103,7 +1103,7 @@ class TestClusterRedisCommands: def _wait_for_bgsave(self, r, timeout=10): deadline = monotonic() + timeout while True: - info = r.info("persistence") + info = r.info("persistence", target_nodes=r.get_default_node()) if int(info.get("rdb_bgsave_in_progress", 0)) == 0: return if monotonic() > deadline: