From 913fad0af49ebb6d1def877a4995c0cd65387ff5 Mon Sep 17 00:00:00 2001 From: Tom Gabsow Date: Tue, 20 Oct 2026 13:58:16 +0300 Subject: [PATCH] Retry SAVE when a background save is already in progress --- RLTest/redis_std.py | 17 +++++++++++- tests/unit/test_redis_std.py | 54 ++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/RLTest/redis_std.py b/RLTest/redis_std.py index 43fe99f..ff2b9d0 100644 --- a/RLTest/redis_std.py +++ b/RLTest/redis_std.py @@ -19,6 +19,7 @@ _TERMINATE_TIMEOUT = 30 _INSTRUMENTED_TERMINATE_TIMEOUT = 300 _KILL_TIMEOUT = 5 +_SAVE_RETRY_TIMEOUT = 10 class StandardEnv(object): @@ -658,7 +659,21 @@ def dumpAndReload(self, restart=False, shardId=None, timeout_sec=0): self.stopEnv() self.startEnv() else: - [con.save() for con in conns] + for con in conns: + deadline = time.monotonic() + _SAVE_RETRY_TIMEOUT + while True: + try: + # A concurrent BGSAVE may hold an older snapshot. We still + # need our own successful SAVE before reloading. + con.save() + break + except redis.ResponseError as err: + if str(err) != 'Background save already in progress': + raise + remaining = deadline - time.monotonic() + if remaining <= 0: + raise + time.sleep(min(0.05, remaining)) try: # Given we've already saved on the prior step, there is no need to SAVE again on the DEBUG RELOAD [con.execute_command('DEBUG', 'RELOAD', 'NOSAVE') for con in conns] diff --git a/tests/unit/test_redis_std.py b/tests/unit/test_redis_std.py index 8a24e8f..f3c305b 100644 --- a/tests/unit/test_redis_std.py +++ b/tests/unit/test_redis_std.py @@ -2,6 +2,9 @@ import shutil import tempfile from unittest import TestCase +from unittest.mock import Mock, call, patch + +import redis from RLTest.redis_std import StandardEnv from tests.unit.test_common import REDIS_BINARY, TLS_CERT, TLS_KEY, TLS_CACERT @@ -11,6 +14,57 @@ tlsCaCertFile = 'fake_ca.crt' +class TestDumpAndReload(TestCase): + def make_env(self, slave=False): + env = StandardEnv.__new__(StandardEnv) + env.useSlaves = slave + env.getConnection = Mock(return_value=Mock()) + env.getSlaveConnection = Mock(return_value=Mock()) + return env + + @patch('RLTest.redis_std.time.sleep') + def test_busy_save_retries_on_master_and_replica(self, sleep): + env = self.make_env(slave=True) + for conn in (env.getConnection(), env.getSlaveConnection()): + conn.save.side_effect = [ + redis.ResponseError('Background save already in progress'), True] + env.dumpAndReload() + for conn in (env.getConnection(), env.getSlaveConnection()): + self.assertEqual(conn.mock_calls, [ + call.save(), call.save(), + call.execute_command('DEBUG', 'RELOAD', 'NOSAVE')]) + self.assertEqual(sleep.call_count, 2) + + @patch('RLTest.redis_std.time.sleep') + @patch('RLTest.redis_std.time.monotonic', side_effect=[0, 10]) + def test_busy_save_timeout_does_not_reload(self, monotonic, sleep): + env = self.make_env() + conn = env.getConnection() + error = redis.ResponseError('Background save already in progress') + conn.save.side_effect = error + with self.assertRaises(redis.ResponseError) as raised: + env.dumpAndReload() + self.assertIs(raised.exception, error) + conn.save.assert_called_once_with() + conn.execute_command.assert_not_called() + sleep.assert_not_called() + + @patch('RLTest.redis_std.time.sleep') + def test_other_save_errors_are_not_retried(self, sleep): + for error in (redis.ResponseError('Permission denied'), + redis.ConnectionError('Connection lost')): + with self.subTest(error=error): + env = self.make_env() + conn = env.getConnection() + conn.save.side_effect = error + with self.assertRaises(type(error)) as raised: + env.dumpAndReload() + self.assertIs(raised.exception, error) + conn.save.assert_called_once_with() + conn.execute_command.assert_not_called() + sleep.assert_not_called() + + class TestStandardEnv(TestCase): def setUp(self):