Files
vector/tests/consensus_tests/test_failed_snapshot_recovery.py
tellet-qandgenerall c5070739f5 Add a test for recovery after kill during Partial (#7762)
* Add a test for recovery after kill during Partial

* Address AI review

* simplify test

* Merge pull request #7829

* introduce a new state

* switch to ManualRecovery for user-initiated snapshot operations

* fmt

* fix test and regen api

* test fix: instead of force recovery on update failed snapshots now re…

---------

Co-authored-by: generall <[email protected]>
2026-02-09 22:35:29 +01:00

388 lines
15 KiB
Python

import pathlib
import subprocess
import tempfile
import shutil
import requests
from time import sleep
from .fixtures import create_collection, upsert_random_points, scroll
from .utils import *
N_PEERS = 3
N_SHARDS = 1
N_REPLICAS = 3
COLLECTION_NAME = "test_collection"
def create_snapshot(peer_api_uri):
r = requests.post(f"{peer_api_uri}/collections/{COLLECTION_NAME}/snapshots")
assert_http_ok(r)
return r.json()["result"]["name"]
def get_peer_id(peer_api_uri):
r = requests.get(f"{peer_api_uri}/cluster")
assert_http_ok(r)
return r.json()["result"]["peer_id"]
def get_local_shards(peer_api_uri):
r = requests.get(f"{peer_api_uri}/collections/{COLLECTION_NAME}/cluster")
assert_http_ok(r)
return r.json()["result"]['local_shards']
def get_remote_shards(peer_api_uri):
r = requests.get(f"{peer_api_uri}/collections/{COLLECTION_NAME}/cluster")
assert_http_ok(r)
return r.json()["result"]['remote_shards']
def fail_to_recover_snapshot(peer_api_uri, snapshot_url):
r = requests.put(f"{peer_api_uri}/collections/{COLLECTION_NAME}/snapshots/recover",
json={"location": snapshot_url})
assert r.status_code == 500
assert "Failed to read segment state" in r.json()["status"]["error"]
def recover_shard(peer_api_uri, collection_name):
collection_cluster_info = get_collection_cluster_info(peer_api_uri, collection_name)
# Example shape of collection_cluster_info:
# {
# 'peer_id': 3037030300099572,
# 'shard_count': 1,
# 'local_shards': [
# {'shard_id': 0, 'points_count': 0, 'state': 'ManualRecovery'}
# ],
# 'remote_shards': [
# {'shard_id': 0, 'peer_id': 8298468227836786, 'state': 'Active'},
# {'shard_id': 0, 'peer_id': 4374571234977199, 'state': 'Active'}
# ]
# }
# Find peer id where status is not Active in local_shards
local_shards = collection_cluster_info["local_shards"]
dead_shard_info = next(shard for shard in local_shards if shard["state"] != "Active")
shard_id = dead_shard_info["shard_id"]
to_peer_id = collection_cluster_info["peer_id"]
# Find a peer id from remote_shards to replicate from, shard_id should be the same
remote_shards = collection_cluster_info["remote_shards"]
from_peer_id = next(shard["peer_id"] for shard in remote_shards if shard["shard_id"] == shard_id)
r = requests.post(
f"{peer_api_uri}/collections/{collection_name}/cluster",
json={
"replicate_shard": {
"from_peer_id": from_peer_id,
"to_peer_id": to_peer_id,
"shard_id": shard_id
}
})
assert r.status_code == 200
def first_segment_name(peer_storage: str, collection_name: str) -> str:
# get first segment name from storage
shard_path = f"{peer_storage}/storage/collections/{collection_name}/0/segments"
segment_name_path = next(filter(lambda x: x.is_dir(), pathlib.Path(shard_path).iterdir()))
return segment_name_path.name
def shard_initializing_flag(peer_storage: str, collection_name: str, shard_id: int) -> str:
return f"{peer_storage}/storage/collections/{collection_name}/shard_{shard_id}.initializing"
def corrupt_snapshot(snapshot_path: pathlib.Path, segment_name: str):
with tempfile.TemporaryDirectory() as temp_dir:
# Extract the snapshot tar
subprocess.run(["tar", "--extract", "--sparse", "--file", snapshot_path, "--directory", temp_dir], check=True)
# Find the inner segment tar file
segment_archive = f"0/segments/{segment_name}.tar"
extracted_segment_tar = os.path.join(temp_dir, segment_archive)
if os.path.exists(extracted_segment_tar):
# Modify the segment tar to remove the `segment.json` file
corrupted_inner_tar = extracted_segment_tar + "-corrupted"
remove_file_from_tar(extracted_segment_tar, "snapshot/files/segment.json", corrupted_inner_tar)
# Replace the original inner segment tar with the corrupted one
os.rename(corrupted_inner_tar, extracted_segment_tar)
# Repack the snapshot tar after modifying the inner segment tar
new_segment_archive = f"{temp_dir}-corrupted"
subprocess.run(["tar", "--create", "--sparse", "--file", new_segment_archive, "-C", temp_dir, "."], check=True)
# Replace the original snapshot tar with the corrupted one
os.rename(new_segment_archive, snapshot_path)
def corrupt_shard_dir(shard_path: pathlib.Path):
wal_path = shard_path / "wal"
shutil.rmtree(wal_path)
def remove_file_from_tar(original_tar, file_to_remove, new_tar):
file_to_remove = file_to_remove.replace(os.sep, "/")
with tempfile.TemporaryDirectory() as temp_dir:
# Extract the inner tar
subprocess.run(["tar", "--extract", "--sparse", "--file", original_tar, "--directory", temp_dir], check=True)
# Remove the target file
file_path = os.path.join(temp_dir, file_to_remove)
if os.path.exists(file_path):
os.remove(file_path)
else:
assert False, f"File {file_to_remove} not found in {original_tar}"
# Repack the inner tar
subprocess.run(["tar", "--create", "--sparse", "--file", new_tar, "-C", temp_dir, "."], check=True)
# The test validates that a node can recover from a corrupted snapshot
def test_corrupted_snapshot_recovery(tmp_path: pathlib.Path):
assert_project_root()
peer_api_uris, peer_dirs, bootstrap_uri = start_cluster(tmp_path, N_PEERS)
create_collection(peer_api_uris[0], shard_number=N_SHARDS, replication_factor=N_REPLICAS)
wait_collection_exists_and_active_on_all_peers(collection_name=COLLECTION_NAME, peer_api_uris=peer_api_uris)
wait_for_same_commit(peer_api_uris=peer_api_uris)
n_points = 3_000
upsert_random_points(peer_api_uris[0], n_points)
query_city = "London"
initial_scroll_result = scroll(peer_api_uris[0], query_city)
assert len(initial_scroll_result) > 0
snapshot_name = create_snapshot(peer_api_uris[-1])
assert snapshot_name is not None
snapshot_path = Path(peer_dirs[-1]) / "snapshots" / COLLECTION_NAME / snapshot_name
assert snapshot_path.exists()
# get first segment name from storage
segment_name = first_segment_name(peer_dirs[-1], COLLECTION_NAME)
# corrupt snapshot
corrupt_snapshot(snapshot_path=snapshot_path, segment_name=segment_name)
# All nodes share the same snapshot directory, so it is fine to use any
snapshot_url = f"{peer_api_uris[-1]}/collections/{COLLECTION_NAME}/snapshots/{snapshot_name}"
print(f"Recovering snapshot {snapshot_url} on {peer_api_uris[-1]}")
# Recover snapshot should fail because the snapshot is corrupted
fail_to_recover_snapshot(peer_api_uris[-1], snapshot_url)
# Assert storage contains initialized flag
flag_path = shard_initializing_flag(peer_dirs[-1], COLLECTION_NAME, 0)
assert os.path.exists(flag_path)
# Kill last peer
p = processes.pop()
p.kill()
sleep(1) # Give killed peer time to release WAL lock
# Restart same peer
peer_api_uris[-1] = start_peer(peer_dirs[-1], f"peer_{N_PEERS}_restarted.log", bootstrap_uri)
# Assert the node does not crash when starting with data from corrupted snapshot
try:
wait_for_collection(peer_api_uris[-1], COLLECTION_NAME)
except Exception as e:
raise Exception(f"Qdrant collection did not start in time after recovering corrupt snapshot, maybe Qdrant crashed: {e}")
flag_exists = os.path.exists(flag_path)
transfers = get_collection_cluster_info(peer_api_uris[-1], COLLECTION_NAME)["shard_transfers"]
if len(transfers) == 0:
# Assert storage contains initialized flag after restart (this means a dummy replica is loaded)
# Sometimes restart is fast and flag is deleted immediately because we initiate a transfer
assert flag_exists
# Initiate transfer from another peer to recover the shard.
# This part checks that dummy shard can be recovered with shard transfer.
recover_shard(peer_api_uris[-1], collection_name=COLLECTION_NAME)
# Assert storage does not contain initialized flag when transfer is started
print("Checking that the shard initializing flag was removed after recovery")
try:
wait_for(lambda : not os.path.exists(flag_path))
except Exception as e:
raise Exception(f"Flag {flag_path} still exists after recovery: {e}")
# There are two other replicas, try moving shards into broken state
local_shards = get_local_shards(peer_api_uris[0])
assert len(local_shards) == 1
assert local_shards[0]["shard_id"] == 0
assert local_shards[0]["state"] == "Active"
assert local_shards[0]["points_count"] == n_points
# Wait for end of shard transfer
wait_for_collection_shard_transfers_count(peer_api_uris[0], COLLECTION_NAME, 0)
# Wait for all replicas to be active on the receiving peer
wait_for_all_replicas_active(peer_api_uris[-1], COLLECTION_NAME)
# Assert that the local shard is active and not empty
local_shards = get_local_shards(peer_api_uris[-1])
assert len(local_shards) == 1
assert local_shards[0]["shard_id"] == 0
assert local_shards[0]["state"] == "Active"
assert local_shards[0]["points_count"] == n_points
# Assert that the remote shards are active and not empty
# The peer used as source for the transfer is used as remote to have at least one
remote_shards = get_remote_shards(peer_api_uris[-1])
assert len(remote_shards) == 2
for shard in remote_shards:
assert shard["state"] == "Active"
# Assert that the remote shards are active and not empty
remote_shards = get_remote_shards(peer_api_uris[0])
assert len(remote_shards) == 2
for shard in remote_shards:
assert shard["state"] == "Active"
# Check that 'scroll' returns the same results after recovery
new_scroll_result = scroll(peer_api_uris[-1], query_city)
assert len(new_scroll_result) == len(initial_scroll_result)
new_ids = [r["id"] for r in new_scroll_result]
initial_ids = [r["id"] for r in initial_scroll_result]
assert new_ids == initial_ids, (new_ids, initial_ids)
@pytest.mark.parametrize("transfer_method", ["snapshot", "stream_records", "wal_delta"])
def test_dirty_shard_handling_with_active_replicas(tmp_path: pathlib.Path, transfer_method: str):
assert_project_root()
extra_env = {
"QDRANT__STORAGE__SHARD_TRANSFER_METHOD": transfer_method,
"QDRANT__STORAGE__HANDLE_COLLECTION_LOAD_ERRORS": "false"
}
peer_api_uris, peer_dirs, bootstrap_uri = start_cluster(
tmp_path,
N_PEERS,
extra_env=extra_env,
)
create_collection(
peer_api_uris[0], shard_number=N_SHARDS, replication_factor=N_REPLICAS
)
wait_collection_exists_and_active_on_all_peers(
collection_name=COLLECTION_NAME, peer_api_uris=peer_api_uris
)
wait_for_same_commit(peer_api_uris=peer_api_uris)
n_points = 3_000
upsert_random_points(peer_api_uris[0], n_points)
query_city = "London"
initial_scroll_result = scroll(peer_api_uris[0], query_city)
assert len(initial_scroll_result) > 0
# Simulate killing on snapshot recovery (corrupting shard dir and adding a shard initializing flag)
# this can happen in practice when a node is killed while shard directory was being moved from /qdrant/snapshots to /qdrant/storage
# the initializing flag is created but never deleted in such cases, when Qdrant restarts it considers it as dirty shard and tries to recover it
shard_id = 0
flag_path = shard_initializing_flag(peer_dirs[-1], COLLECTION_NAME, shard_id)
Path(flag_path).touch()
# Delete some of the files from the shard:
shard_path = Path(peer_dirs[-1]) / "storage" / "collections" / COLLECTION_NAME / f"{shard_id}"
assert shard_path.exists()
corrupt_shard_dir(shard_path)
# Kill last peer
p = processes.pop()
p.kill()
sleep(1) # Give killed peer time to release WAL lock
# Restart same peer
peer_api_uris[-1] = start_peer(
peer_dirs[-1], f"peer_{N_PEERS}_restarted.log", bootstrap_uri,
extra_env=extra_env
)
# Upsert one point to mark dummy replica as dead, that will trigger recovery transfer
upsert_random_points(peer_api_uris[0], 1, offset=10000)
n_points += 1
# Wait for start of shard transfer
wait_for_collection_shard_transfers_count(peer_api_uris[0], COLLECTION_NAME, 1)
print("Checking that the shard initializing flag was removed after transfer was started")
try:
def check_flag_deleted():
res = requests.get(f"{peer_api_uris[-1]}/collections/{COLLECTION_NAME}/cluster").text
print(res)
return not os.path.exists(flag_path)
wait_for(check_flag_deleted)
except Exception as e:
raise Exception(f"Flag {flag_path} still exists after recovery: {e}")
# Kill again after transfer starts (shard initializing flag has been deleted and shard is empty)
p = processes.pop()
p.kill()
sleep(1) # Give killed peer time to release WAL lock
# Restart same peer again
peer_api_uris[-1] = start_peer(
peer_dirs[-1], f"peer_{N_PEERS}_restarted_again.log", bootstrap_uri,
extra_env=extra_env
)
wait_for_same_commit(peer_api_uris=peer_api_uris)
# transfer might have been still in progress when the peer was killed. so we wait for it to finish
wait_for_collection_shard_transfers_count(peer_api_uris[-1], COLLECTION_NAME, 0)
# Wait for all replicas to be active on the receiving peer
wait_for_all_replicas_active(peer_api_uris[-1], COLLECTION_NAME)
# Assert that the local shard is active and not empty
try:
[local_shard] = get_local_shards(peer_api_uris[-1])
assert local_shard["shard_id"] == 0
assert local_shard["state"] == "Active"
assert local_shard["points_count"] == n_points
except ValueError as e:
res = requests.get(f"{peer_api_uris[-1]}/collections/{COLLECTION_NAME}/cluster").text
print(res, e)
raise e
# shard initializing flag should remain dropped after recovery is successful
print("Checking that the shard initializing flag remains removed after recovery")
try:
def check_flag_deleted():
res = requests.get(f"{peer_api_uris[-1]}/collections/{COLLECTION_NAME}/cluster").text
print(res)
return not os.path.exists(flag_path)
wait_for(check_flag_deleted)
except Exception as e:
raise Exception(f"Flag {flag_path} still exists after recovery: {e}")
# Assert that the remote shards are active and not empty
# The peer used as source for the transfer is used as remote to have at least one
remote_shards = get_remote_shards(peer_api_uris[-1])
assert len(remote_shards) == 2
for shard in remote_shards:
assert shard["state"] == "Active"
# Assert that the remote shards are active and not empty
remote_shards = get_remote_shards(peer_api_uris[0])
assert len(remote_shards) == 2
for shard in remote_shards:
assert shard["state"] == "Active"
# Check that 'scroll' returns the same results after recovery
new_scroll_result = scroll(peer_api_uris[-1], query_city)
assert len(new_scroll_result) == len(initial_scroll_result)
new_ids = [r["id"] for r in new_scroll_result]
initial_ids = [r["id"] for r in initial_scroll_result]
assert new_ids == initial_ids, (new_ids, initial_ids)