import pytest import logging import os import glob import asyncio from async_timeout import timeout import redis from redis import asyncio as aioredis from pathlib import Path import boto3 from .instance import RedisServer from random import randint as rand import string import random from pymemcache.client.base import Client as MCClient from . import dfly_args from .utility import wait_available_async, is_saving, tmp_file_name from .seeder import StaticSeeder BASIC_ARGS = {"dir": "{DRAGONFLY_TMP}/", "proactor_threads": 4} FILE_FORMATS = ["RDB", "DF"] # Should be used where text auxiliary mechanisms like filenames LIGHTWEIGHT_SEEDER_ARGS = dict(key_target=100, data_size=100, variance=1, samples=1) def find_main_file(path: Path, pattern): return next(iter(glob.glob(str(path) + "/" + pattern)), None) async def get_metric_value(inst, metric_name, sample_index=0): return (await inst.metrics())[metric_name].samples[sample_index].value async def assert_metric_value(inst, metric_name, expected_value): actual_value = await get_metric_value(inst, metric_name) assert ( actual_value == expected_value ), f"Expected {metric_name} to be {expected_value}, got ${actual_value}" @pytest.mark.opt_only @pytest.mark.parametrize("format", FILE_FORMATS) @pytest.mark.parametrize( "seeder_opts", [ # Many small keys, high variance dict(key_target=50_000, data_size=100, variance=10, samples=50), # A few large keys, high variance dict(key_target=1000, data_size=5_000, variance=10, samples=10), ], ) @dfly_args({**BASIC_ARGS, "proactor_threads": 4}) async def test_consistency(df_factory, format: str, seeder_opts: dict): """ Test consistency over a large variety of data with different sizes """ dbfilename = f"dump_{tmp_file_name()}" instance = df_factory.create(dbfilename=dbfilename) instance.start() async_client = instance.client() await StaticSeeder(**seeder_opts).run(async_client) start_capture = await StaticSeeder.capture(async_client) # save + flush + load await async_client.execute_command("SAVE", format) assert await async_client.flushall() await async_client.execute_command( "DFLY", "LOAD", f"{dbfilename}.rdb" if format == "RDB" else f"{dbfilename}-summary.dfs", ) assert (await StaticSeeder.capture(async_client)) == start_capture @pytest.mark.parametrize("format", FILE_FORMATS) @dfly_args({**BASIC_ARGS, "proactor_threads": 4}) async def test_multidb(df_factory, format: str): """ Test serialization of multiple logical databases """ dbfilename = f"dump_{tmp_file_name()}" instance = df_factory.create(dbfilename=dbfilename) instance.start() async_client = instance.client() start_captures = [] for dbid in range(10): db_client = instance.client(db=dbid) await StaticSeeder(key_target=1000).run(db_client) start_captures.append(await StaticSeeder.capture(db_client)) # save + flush + load await async_client.execute_command("SAVE", format) assert await async_client.flushall() await async_client.execute_command( "DFLY", "LOAD", f"{dbfilename}.rdb" if format == "RDB" else f"{dbfilename}-summary.dfs", ) for dbid in range(10): db_client = instance.client(db=dbid) assert (await StaticSeeder.capture(db_client)) == start_captures[dbid] @pytest.mark.asyncio @pytest.mark.parametrize( "save_type, dbfilename, pattern", [ ("rdb", "test-autoload1-{{timestamp}}", "test-autoload1-*.rdb"), ("df", "test-autoload2-{{timestamp}}", "test-autoload2-*-summary.dfs"), ("rdb", "test-autoload3-{{timestamp}}.rdb", "test-autoload3-*.rdb"), ("rdb", "test-autoload4", "test-autoload4.rdb"), ("df", "test-autoload5", "test-autoload5-summary.dfs"), ("rdb", "test-autoload6.rdb", "test-autoload6.rdb"), ], ) async def test_dbfilenames( df_factory, tmp_dir: Path, save_type: str, dbfilename: str, pattern: str ): df_args = {**BASIC_ARGS, "dbfilename": dbfilename, "port": 1111} if save_type == "rdb": df_args["nodf_snapshot_format"] = None start_capture = None with df_factory.create(**df_args) as df_server: async with df_server.client() as client: await wait_available_async(client) # We use the seeder just to check we don't loose any files (and thus keys) await StaticSeeder(**LIGHTWEIGHT_SEEDER_ARGS).run(client) start_capture = await StaticSeeder.capture(client) await client.execute_command("SAVE " + save_type) file = find_main_file(tmp_dir, pattern) assert file is not None assert os.path.basename(file).startswith(dbfilename.split("{{")[0]) with df_factory.create(**df_args) as df_server: async with df_server.client() as client: await wait_available_async(client) assert await StaticSeeder.capture(client) == start_capture @pytest.mark.asyncio @dfly_args( { **BASIC_ARGS, "proactor_threads": 4, "dbfilename": "test-redis-load-rdb", "list_rdb_encode_v2": "false", # Needed for compatibility with Redis 6 } ) async def test_redis_load_snapshot( async_client: aioredis.Redis, df_server, redis_local_server: RedisServer, tmp_dir: Path ): """ Test redis server loading dragonfly snapshot rdb format """ await StaticSeeder( **LIGHTWEIGHT_SEEDER_ARGS, types=["STRING", "LIST", "SET", "HASH", "ZSET"] ).run(async_client) await async_client.lpush("list", "A" * 10_000) await async_client.execute_command("SAVE", "rdb") dbsize = await async_client.dbsize() await async_client.connection_pool.disconnect() df_server.stop() redis_local_server.start(dir=tmp_dir, dbfilename="test-redis-load-rdb.rdb") await asyncio.sleep(1) c_master = aioredis.Redis(port=redis_local_server.port) await c_master.ping() assert await c_master.dbsize() == dbsize @pytest.mark.slow @dfly_args({**BASIC_ARGS, "dbfilename": "test-cron", "snapshot_cron": "* * * * *"}) async def test_cron_snapshot(tmp_dir: Path, async_client: aioredis.Redis): await StaticSeeder(**LIGHTWEIGHT_SEEDER_ARGS).run(async_client) file = None async with timeout(65): while file is None: await asyncio.sleep(1) file = find_main_file(tmp_dir, "test-cron-summary.dfs") assert file is not None, os.listdir(tmp_dir) @pytest.mark.skip("Fails and also causes all TLS tests to fail") @pytest.mark.slow @dfly_args({**BASIC_ARGS, "dbfilename": "test-failed-saving", "snapshot_cron": "* * * * *"}) async def test_cron_snapshot_failed_saving(df_server, tmp_dir: Path, async_client: aioredis.Redis): await StaticSeeder(**LIGHTWEIGHT_SEEDER_ARGS).run(async_client) backups_total = await get_metric_value(df_server, "dragonfly_backups") failed_backups_total = await get_metric_value(df_server, "dragonfly_failed_backups") file = None async with timeout(65): while file is None: await asyncio.sleep(1) file = find_main_file(tmp_dir, "test-failed-saving-summary.dfs") assert file is not None, os.listdir(tmp_dir) await assert_metric_value(df_server, "dragonfly_backups", backups_total + 1) await assert_metric_value(df_server, "dragonfly_failed_backups", failed_backups_total) # Remove all files from directory for dir_file in tmp_dir.iterdir(): os.unlink(dir_file) # Make directory read-only os.chmod(tmp_dir, 0o555) # Wait for the next SAVE command await asyncio.sleep(65) file = find_main_file(tmp_dir, "test-failed-saving-summary.dfs") # Make directory writable again os.chmod(tmp_dir, 0o777) assert file is None, os.listdir(tmp_dir) await assert_metric_value(df_server, "dragonfly_backups", backups_total + 2) await assert_metric_value(df_server, "dragonfly_failed_backups", failed_backups_total + 1) @pytest.mark.slow @dfly_args({**BASIC_ARGS, "dbfilename": "test-cron-set"}) async def test_set_cron_snapshot(tmp_dir: Path, async_client: aioredis.Redis): await StaticSeeder(**LIGHTWEIGHT_SEEDER_ARGS).run(async_client) await async_client.config_set("snapshot_cron", "* * * * *") file = None async with timeout(65): while file is None: await asyncio.sleep(1) file = find_main_file(tmp_dir, "test-cron-set-summary.dfs") assert file is not None @dfly_args( {**BASIC_ARGS, "dbfilename": "test-save-rename-command", "rename_command": "save=save-foo"} ) async def test_shutdown_save_with_rename(df_server): """Checks that on shutdown we save snapshot""" client = df_server.client() await StaticSeeder(**LIGHTWEIGHT_SEEDER_ARGS).run(client) start_capture = await StaticSeeder.capture(client) await client.connection_pool.disconnect() df_server.stop() df_server.start() client = df_server.client() await wait_available_async(client) assert await StaticSeeder.capture(client) == start_capture await client.connection_pool.disconnect() @pytest.mark.slow async def test_parallel_snapshot(async_client): """Dragonfly does not allow simultaneous save operations, send 2 save operations and make sure one is rejected""" await async_client.execute_command("debug", "populate", "1000000", "askldjh", "1000", "RAND") async def save(): try: await async_client.execute_command("save", "rdb", "dump") return True except Exception as e: return False save_successes = sum(await asyncio.gather(*(save() for _ in range(2))), 0) assert save_successes == 1, "Only one SAVE must be successful" async def test_path_escapes(df_factory): """Test that we don't allow path escapes. We just check that df_server.start() fails because we don't have a much better way to test that.""" df_server = df_factory.create(dbfilename="../../../../etc/passwd") try: df_server.start() assert False, "Server should not start correctly" except Exception as e: pass @dfly_args({**BASIC_ARGS, "dbfilename": "test-info-persistence"}) async def test_info_persistence_field(async_client): """Test is_loading field on INFO PERSISTENCE during snapshot loading""" await StaticSeeder(**LIGHTWEIGHT_SEEDER_ARGS).run(async_client) # Wait for snapshot to finish loading and try INFO PERSISTENCE await wait_available_async(async_client) assert "loading:0" in (await async_client.execute_command("INFO PERSISTENCE")) # If DRAGONFLY_S3_BUCKET is configured, AWS credentials must also be # configured. @pytest.mark.skipif( "DRAGONFLY_S3_BUCKET" not in os.environ or os.environ["DRAGONFLY_S3_BUCKET"] == "", reason="AWS S3 snapshots bucket is not configured", ) @dfly_args({**BASIC_ARGS, "dir": "s3://{DRAGONFLY_S3_BUCKET}{DRAGONFLY_TMP}", "dbfilename": ""}) async def test_s3_snapshot(async_client, tmp_dir): seeder = StaticSeeder(key_target=10_000) await seeder.run(async_client) start_capture = await StaticSeeder.capture(async_client) try: # save + flush + load await async_client.execute_command("SAVE DF snapshot") assert await async_client.flushall() await async_client.execute_command( "DFLY LOAD " + os.environ["DRAGONFLY_S3_BUCKET"] + str(tmp_dir) + "/snapshot-summary.dfs" ) assert await StaticSeeder.capture(async_client) == start_capture finally: def delete_objects(bucket, prefix): client = boto3.client("s3") resp = client.list_objects_v2( Bucket=bucket, Prefix=prefix, ) keys = [] for obj in resp["Contents"]: keys.append({"Key": obj["Key"]}) client.delete_objects( Bucket=bucket, Delete={"Objects": keys}, ) delete_objects( os.environ["DRAGONFLY_S3_BUCKET"], str(tmp_dir)[1:], ) @dfly_args({**BASIC_ARGS, "dbfilename": "test-shutdown"}) class TestDflySnapshotOnShutdown: SEEDER_ARGS = dict(key_target=10_000) """Test multi file snapshot""" async def _get_info_memory_fields(self, client): res = await client.execute_command("INFO MEMORY") fields = {} for line in res.splitlines(): if line.startswith("#"): continue k, v = line.split(":") if k == "object_used_memory" or k.startswith("type_used_memory_"): fields.update({k: int(v)}) return fields async def _delete_all_keys(self, client: aioredis.Redis): while True: keys = await client.keys() if len(keys) == 0: break await client.delete(*keys) @pytest.mark.asyncio async def test_memory_counters(self, async_client: aioredis.Redis): memory_counters = await self._get_info_memory_fields(async_client) assert memory_counters == {"object_used_memory": 0} seeder = StaticSeeder(**self.SEEDER_ARGS) await seeder.run(async_client) memory_counters = await self._get_info_memory_fields(async_client) assert all(value > 0 for value in memory_counters.values()) await self._delete_all_keys(async_client) memory_counters = await self._get_info_memory_fields(async_client) assert memory_counters == {"object_used_memory": 0} @pytest.mark.asyncio @pytest.mark.slow async def test_snapshot(self, df_server, async_client): """Checks that: 1. After reloading the snapshot file the data is the same 2. Memory counters after loading from snapshot is similar to before creating a snapshot 3. Memory counters after deleting all keys loaded by snapshot - this validates the memory counting when loading from snapshot.""" seeder = StaticSeeder(**self.SEEDER_ARGS) await seeder.run(async_client) start_capture = await StaticSeeder.capture(async_client) memory_before = await self._get_info_memory_fields(async_client) await async_client.connection_pool.disconnect() df_server.stop() df_server.start() async_client = df_server.client() await wait_available_async(async_client) assert await StaticSeeder.capture(async_client) == start_capture memory_after = await self._get_info_memory_fields(async_client) for counter, value in memory_before.items(): # Unfortunately memory usage sometimes depends on order of insertion / deletion, so # it's usually not exactly the same. For the test to be stable we check that it's # at least 50% that of the original value. assert memory_after[counter] >= 0.5 * value await self._delete_all_keys(async_client) memory_empty = await self._get_info_memory_fields(async_client) assert memory_empty == {"object_used_memory": 0} @pytest.mark.parametrize("format", FILE_FORMATS) @dfly_args({**BASIC_ARGS, "dbfilename": "info-while-snapshot"}) async def test_infomemory_while_snapshotting(df_factory, format: str): instance = df_factory.create(dbfilename=f"dump_{tmp_file_name()}") instance.start() async_client = instance.client() await async_client.execute_command("DEBUG POPULATE 10000 key 4048 RAND") async def save(): await async_client.execute_command("SAVE", format) save_finished = False async def info_in_loop(): while not save_finished: await async_client.execute_command("INFO MEMORY") await asyncio.sleep(0.1) save_task = asyncio.create_task(save()) info_task = asyncio.create_task(info_in_loop()) await save_task save_finished = True await info_task @dfly_args({**BASIC_ARGS, "dbfilename": "test-bgsave"}) async def test_bgsave_and_save(async_client: aioredis.Redis): await async_client.execute_command("DEBUG POPULATE 200000") await async_client.execute_command("BGSAVE") with pytest.raises(redis.exceptions.ResponseError): await async_client.execute_command("BGSAVE") while await is_saving(async_client): await asyncio.sleep(0.1) await async_client.execute_command("BGSAVE") with pytest.raises(redis.exceptions.ResponseError): await async_client.execute_command("SAVE") while await is_saving(async_client): await asyncio.sleep(0.1) await async_client.execute_command("SAVE") @dfly_args( { **BASIC_ARGS, "proactor_threads": 4, "dbfilename": "tiered-entries", "tiered_prefix": "tiering-test-backing", "tiered_offload_threshold": "0.0", # ask offloading loop to offload as much as possible } ) async def test_tiered_entries(async_client: aioredis.Redis): """This test makes sure tieried entries are correctly persisted""" # With variance 4: 512 - 8192 we include small and large values await StaticSeeder(key_target=5000, data_size=1024, variance=4, types=["STRING"]).run( async_client ) # Compute the capture, this brings all items back to memory... so we'll wait for offloading start_capture = await StaticSeeder.capture(async_client) # Wait until the total_stashes counter stops increasing, meaning offloading finished last_writes, current_writes = 0, -1 while last_writes != current_writes: await asyncio.sleep(0.1) last_writes = current_writes current_writes = (await async_client.info("TIERED"))["tiered_total_stashes"] # Save + flush + load await async_client.execute_command("SAVE", "DF") assert await async_client.flushall() await async_client.execute_command( "DFLY", "LOAD", "tiered-entries-summary.dfs", ) # Compare captures assert await StaticSeeder.capture(async_client) == start_capture @pytest.mark.skip("Too heavy") @pytest.mark.opt_only @dfly_args( { **BASIC_ARGS, "proactor_threads": 4, "maxmemory": "1G", "dbfilename": "tiered-entries", "tiered_prefix": "tiering-test-backing", "tiered_offload_threshold": "0.5", # ask to keep below 0.5 * 1G "tiered_storage_write_depth": 50, } ) async def test_tiered_entries_throttle(async_client: aioredis.Redis): """This test makes sure tieried entries are correctly persisted""" await StaticSeeder(key_target=600_000, data_size=4096, variance=1, types=["STRING"]).run( async_client ) # Compute the capture, this brings all items back to memory... so we'll wait for offloading start_capture = await StaticSeeder.capture(async_client) # Save + flush + load await async_client.execute_command("SAVE", "DF") assert await async_client.flushall() load_task = asyncio.create_task( async_client.execute_command( "DFLY", "LOAD", "tiered-entries-summary.dfs", ) ) while not load_task.done(): info = await async_client.info("ALL") # print(info["used_memory_human"], info["used_memory_rss_human"]) assert info["used_memory"] < 600e6 # less than 600mb, await asyncio.sleep(0.05) await load_task assert await StaticSeeder.capture(async_client) == start_capture @dfly_args({"serialization_max_chunk_size": 4096, "proactor_threads": 1}) @pytest.mark.parametrize( "cont_type", [ ("HASH"), ("SET"), ("ZSET"), ("LIST"), ], ) @pytest.mark.slow async def test_big_value_serialization_memory_limit(df_factory, cont_type): dbfilename = f"dump_{tmp_file_name()}" instance = df_factory.create(dbfilename=dbfilename) instance.start() client = instance.client() one_gb = 1_000_000_000 elements = 1000 element_size = 1_000_000 # 1mb await client.execute_command( f"debug populate 1 prefix {element_size} TYPE {cont_type} RAND ELEMENTS {elements}" ) info = await client.info("ALL") # rss double's because of DEBUG POPULATE assert info["used_memory_peak_rss"] > (one_gb * 2) # if we execute SAVE below without big value serialization we trigger the assertion below. # note the peak would reach (one_gb * 3) without it. await client.execute_command("SAVE") info = await client.info("ALL") upper_limit = 2_250_000_000 # 2.25 GB assert info["used_memory_peak_rss"] < upper_limit await client.execute_command("FLUSHALL") await client.close() @dfly_args( { "dir": "{DRAGONFLY_TMP}/", "memcached_port": 11211, "proactor_threads": 4, "dbfilename": "test-MC-flags", } ) async def test_mc_flags_saving(memcached_client: MCClient, async_client: aioredis.Redis): async def check_flag(key, flag): res = memcached_client.raw_command("get " + key, "END\r\n").split() # workaround sometimes memcached_client.raw_command returns empty str if len(res) > 2: assert res[2].decode() == str(flag) assert memcached_client.set("key1", "value1", noreply=True) assert memcached_client.set("key2", "value1", noreply=True, expire=3600, flags=123456) assert memcached_client.replace("key1", "value2", expire=4000, flags=2, noreply=True) await check_flag("key1", 2) await check_flag("key2", 123456) await async_client.execute_command("SAVE", "DF") assert await async_client.flushall() await async_client.execute_command( "DFLY", "LOAD", "test-MC-flags-summary.dfs", ) await check_flag("key1", 2) await check_flag("key2", 123456)