mirror of
https://github.com/qdrant/qdrant.git
synced 2026-09-27 16:37:42 -05:00
* WIP: introduce local state * WIP: sync local state with consensus in idle * fmt * rm extensive debug * update triple replication test * update triple replication test * rm debug logs * fix check for established leader + only sync local if no proposals * remove unused file * test compatible with python 3.8 * test compatible with python 3.8 * longer wait for consensus * longer wait for consensus * extra sleep in test * remove extra sleep * Fixing missed leader inconsistency - transfers (#1298) * explicit request timeout * explicit request timeout * explicit request timeout * explicit request timeout * explicit request timeout * prevent double handelling of the transfer termination * kill the process * log on inconsistency * log on inconsistency * log on inconsistency * debug * revert debug in test * forward updates to partial shards, abort transactions on dead node report * fmt * disable retry of transfer, if the transfer was cancelled + allow predictable ports in test * fix import
372 lines
13 KiB
Python
372 lines
13 KiB
Python
import json
|
|
import os
|
|
import shutil
|
|
from subprocess import Popen
|
|
import time
|
|
from typing import Tuple, Callable, Dict, List
|
|
import requests
|
|
import socket
|
|
from contextlib import closing
|
|
from pathlib import Path
|
|
import pytest
|
|
from .assertions import assert_http_ok
|
|
|
|
# Tracks processes that need to be killed at the end of the test
|
|
processes = []
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def every_test():
|
|
yield
|
|
print()
|
|
while len(processes) > 0:
|
|
p = processes.pop(0)
|
|
print(f"Killing {p.pid}")
|
|
p.kill()
|
|
|
|
|
|
def get_port() -> int:
|
|
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
|
|
s.bind(('', 0))
|
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
return s.getsockname()[1]
|
|
|
|
|
|
def get_env(p2p_port: int, grpc_port: int, http_port: int) -> Dict[str, str]:
|
|
env = os.environ.copy()
|
|
env["QDRANT__CLUSTER__ENABLED"] = "true"
|
|
env["QDRANT__CLUSTER__P2P__PORT"] = str(p2p_port)
|
|
env["QDRANT__SERVICE__HTTP_PORT"] = str(http_port)
|
|
env["QDRANT__SERVICE__GRPC_PORT"] = str(grpc_port)
|
|
env["QDRANT__LOG_LEVEL"] = "DEBUG,raft::raft=info"
|
|
return env
|
|
|
|
|
|
def get_uri(port: int) -> str:
|
|
return f"http://127.0.0.1:{port}"
|
|
|
|
|
|
def assert_project_root():
|
|
directory_path = os.getcwd()
|
|
folder_name = os.path.basename(directory_path)
|
|
assert folder_name == "qdrant"
|
|
|
|
|
|
def get_qdrant_exec() -> str:
|
|
directory_path = os.getcwd()
|
|
qdrant_exec = directory_path + "/target/debug/qdrant"
|
|
return qdrant_exec
|
|
|
|
|
|
# Starts a peer and returns its api_uri
|
|
def start_peer(peer_dir: Path, log_file: str, bootstrap_uri: str, port=None) -> str:
|
|
p2p_port = get_port() if port is None else port + 0
|
|
grpc_port = get_port() if port is None else port + 1
|
|
http_port = get_port() if port is None else port + 2
|
|
env = get_env(p2p_port, grpc_port, http_port)
|
|
log_file = open(log_file, "w")
|
|
print(f"Starting follower peer with bootstrap uri {bootstrap_uri},"
|
|
f" http: http://localhost:{http_port}/cluster, p2p: {p2p_port}")
|
|
|
|
this_peer_consensus_uri = get_uri(p2p_port)
|
|
processes.append(
|
|
Popen([get_qdrant_exec(), "--bootstrap", bootstrap_uri, "--uri", this_peer_consensus_uri], env=env,
|
|
cwd=peer_dir, stderr=log_file))
|
|
return get_uri(http_port)
|
|
|
|
|
|
# Starts a peer and returns its api_uri and p2p_uri
|
|
def start_first_peer(peer_dir: Path, log_file: str, port=None) -> Tuple[str, str]:
|
|
p2p_port = get_port() if port is None else port + 0
|
|
grpc_port = get_port() if port is None else port + 1
|
|
http_port = get_port() if port is None else port + 2
|
|
env = get_env(p2p_port, grpc_port, http_port)
|
|
log_file = open(log_file, "w")
|
|
bootstrap_uri = get_uri(p2p_port)
|
|
print(f"\nStarting first peer with uri {bootstrap_uri},"
|
|
f" http: http://localhost:{http_port}/cluster, p2p: {p2p_port}")
|
|
processes.append(
|
|
Popen([get_qdrant_exec(), "--uri", bootstrap_uri], env=env, cwd=peer_dir, stderr=log_file))
|
|
return get_uri(http_port), bootstrap_uri
|
|
|
|
|
|
def start_cluster(tmp_path, num_peers, port_seed=None):
|
|
assert_project_root()
|
|
peer_dirs = make_peer_folders(tmp_path, num_peers)
|
|
|
|
# Gathers REST API uris
|
|
peer_api_uris = []
|
|
|
|
# Start bootstrap
|
|
(bootstrap_api_uri, bootstrap_uri) = start_first_peer(peer_dirs[0], "peer_0_0.log", port=port_seed)
|
|
peer_api_uris.append(bootstrap_api_uri)
|
|
|
|
# Wait for leader
|
|
leader = wait_peer_added(bootstrap_api_uri)
|
|
|
|
port = None
|
|
# Start other peers
|
|
for i in range(1, len(peer_dirs)):
|
|
if port_seed is not None:
|
|
port = port_seed + i * 100
|
|
peer_api_uris.append(start_peer(peer_dirs[i], f"peer_0_{i}.log", bootstrap_uri, port=port))
|
|
|
|
# Wait for cluster
|
|
wait_for_uniform_cluster_status(peer_api_uris, leader)
|
|
|
|
return peer_api_uris, peer_dirs, bootstrap_uri
|
|
|
|
|
|
def make_peer_folder(base_path: Path, peer_number: int) -> Path:
|
|
peer_dir = base_path / f"peer{peer_number}"
|
|
peer_dir.mkdir()
|
|
shutil.copytree("config", peer_dir / "config")
|
|
return peer_dir
|
|
|
|
|
|
def make_peer_folders(base_path: Path, n_peers: int) -> List[Path]:
|
|
peer_dirs = []
|
|
for i in range(n_peers):
|
|
peer_dir = make_peer_folder(base_path, i)
|
|
peer_dirs.append(peer_dir)
|
|
return peer_dirs
|
|
|
|
|
|
def get_cluster_info(peer_api_uri: str) -> dict:
|
|
r = requests.get(f"{peer_api_uri}/cluster")
|
|
assert_http_ok(r)
|
|
res = r.json()["result"]
|
|
return res
|
|
|
|
|
|
def print_clusters_info(peer_api_uris: [str]):
|
|
for uri in peer_api_uris:
|
|
print(json.dumps(get_cluster_info(uri), indent=4))
|
|
|
|
|
|
def get_collection_cluster_info(peer_api_uri: str, collection_name: str) -> dict:
|
|
r = requests.get(f"{peer_api_uri}/collections/{collection_name}/cluster")
|
|
assert_http_ok(r)
|
|
res = r.json()["result"]
|
|
return res
|
|
|
|
|
|
def print_collection_cluster_info(peer_api_uri: str, collection_name: str):
|
|
print(json.dumps(get_collection_cluster_info(peer_api_uri, collection_name), indent=4))
|
|
|
|
|
|
def get_leader(peer_api_uri: str) -> str:
|
|
r = requests.get(f"{peer_api_uri}/cluster")
|
|
assert_http_ok(r)
|
|
return r.json()["result"]["raft_info"]["leader"]
|
|
|
|
|
|
def check_leader(peer_api_uri: str, expected_leader: str) -> bool:
|
|
try:
|
|
r = requests.get(f"{peer_api_uri}/cluster")
|
|
assert_http_ok(r)
|
|
leader = r.json()["result"]["raft_info"]["leader"]
|
|
correct_leader = leader == expected_leader
|
|
if not correct_leader:
|
|
print(f"Cluster leader invalid for peer {peer_api_uri} {leader}/{expected_leader}")
|
|
return correct_leader
|
|
except requests.exceptions.ConnectionError:
|
|
# the api is not yet available - caller needs to retry
|
|
print(f"Could not contact peer {peer_api_uri} to fetch cluster leader")
|
|
return False
|
|
|
|
|
|
def leader_is_defined(peer_api_uri: str) -> bool:
|
|
try:
|
|
r = requests.get(f"{peer_api_uri}/cluster")
|
|
assert_http_ok(r)
|
|
leader = r.json()["result"]["raft_info"]["leader"]
|
|
return leader is not None
|
|
except requests.exceptions.ConnectionError:
|
|
# the api is not yet available - caller needs to retry
|
|
print(f"Could not contact peer {peer_api_uri} to fetch leader info")
|
|
return False
|
|
|
|
|
|
def check_cluster_size(peer_api_uri: str, expected_size: int) -> bool:
|
|
try:
|
|
r = requests.get(f"{peer_api_uri}/cluster")
|
|
assert_http_ok(r)
|
|
peers = r.json()["result"]["peers"]
|
|
correct_size = len(peers) == expected_size
|
|
if not correct_size:
|
|
print(f"Cluster size invalid for peer {peer_api_uri} {len(peers)}/{expected_size}")
|
|
return correct_size
|
|
except requests.exceptions.ConnectionError:
|
|
# the api is not yet available - caller needs to retry
|
|
print(f"Could not contact peer {peer_api_uri} to fetch cluster size")
|
|
return False
|
|
|
|
|
|
def all_nodes_cluster_info_consistent(peer_api_uris: [str], expected_leader: str) -> bool:
|
|
expected_size = len(peer_api_uris)
|
|
for uri in peer_api_uris:
|
|
if check_leader(uri, expected_leader) and check_cluster_size(uri, expected_size):
|
|
continue
|
|
else:
|
|
return False
|
|
return True
|
|
|
|
|
|
def collection_exists_on_all_peers(collection_name: str, peer_api_uris: [str]) -> bool:
|
|
for uri in peer_api_uris:
|
|
r = requests.get(f"{uri}/collections")
|
|
assert_http_ok(r)
|
|
collections = r.json()["result"]["collections"]
|
|
filtered_collections = [c for c in collections if c['name'] == collection_name]
|
|
if len(filtered_collections) == 0:
|
|
print(
|
|
f"Collection '{collection_name}' does not exist on peer {uri} found {json.dumps(collections, indent=4)}")
|
|
return False
|
|
else:
|
|
continue
|
|
return True
|
|
|
|
|
|
def check_collection_local_shards_count(peer_api_uri: str, collection_name: str,
|
|
expected_local_shard_count: int) -> bool:
|
|
collection_cluster_info = get_collection_cluster_info(peer_api_uri, collection_name)
|
|
local_shard_count = len(collection_cluster_info["local_shards"])
|
|
return local_shard_count == expected_local_shard_count
|
|
|
|
|
|
def check_collection_shard_transfers_count(peer_api_uri: str, collection_name: str,
|
|
expected_shard_transfers_count: int) -> bool:
|
|
collection_cluster_info = get_collection_cluster_info(peer_api_uri, collection_name)
|
|
local_shard_count = len(collection_cluster_info["shard_transfers"])
|
|
return local_shard_count == expected_shard_transfers_count
|
|
|
|
|
|
def check_all_replicas_active(peer_api_uri: str, collection_name: str) -> bool:
|
|
collection_cluster_info = get_collection_cluster_info(peer_api_uri, collection_name)
|
|
for shard in collection_cluster_info["local_shards"]:
|
|
if shard['state'] != 'Active':
|
|
return False
|
|
for shard in collection_cluster_info["remote_shards"]:
|
|
if shard['state'] != 'Active':
|
|
return False
|
|
return True
|
|
|
|
|
|
def check_some_replicas_not_active(peer_api_uri: str, collection_name: str) -> bool:
|
|
return not check_all_replicas_active(peer_api_uri, collection_name)
|
|
|
|
|
|
WAIT_TIME_SEC = 30
|
|
RETRY_INTERVAL_SEC = 0.5
|
|
|
|
|
|
def wait_peer_added(peer_api_uri: str, expected_size: int = 1) -> str:
|
|
wait_for(check_cluster_size, peer_api_uri, expected_size)
|
|
wait_for(leader_is_defined, peer_api_uri)
|
|
return get_leader(peer_api_uri)
|
|
|
|
|
|
def wait_for_some_replicas_not_active(peer_api_uri: str, collection_name: str):
|
|
try:
|
|
wait_for(check_some_replicas_not_active, peer_api_uri, collection_name)
|
|
except Exception as e:
|
|
print_clusters_info([peer_api_uri])
|
|
raise e
|
|
|
|
|
|
def wait_for_all_replicas_active(peer_api_uri: str, collection_name: str):
|
|
try:
|
|
wait_for(check_all_replicas_active, peer_api_uri, collection_name)
|
|
except Exception as e:
|
|
print_clusters_info([peer_api_uri])
|
|
raise e
|
|
|
|
|
|
def wait_for_uniform_cluster_status(peer_api_uris: [str], expected_leader: str):
|
|
try:
|
|
wait_for(all_nodes_cluster_info_consistent, peer_api_uris, expected_leader)
|
|
except Exception as e:
|
|
print_clusters_info(peer_api_uris)
|
|
raise e
|
|
|
|
|
|
def wait_for_uniform_collection_existence(collection_name: str, peer_api_uris: [str]):
|
|
try:
|
|
wait_for(collection_exists_on_all_peers, collection_name, peer_api_uris)
|
|
except Exception as e:
|
|
print_clusters_info(peer_api_uris)
|
|
raise e
|
|
|
|
|
|
def wait_for_collection_shard_transfers_count(peer_api_uri: str, collection_name: str,
|
|
expected_shard_transfer_count: int):
|
|
try:
|
|
wait_for(check_collection_shard_transfers_count, peer_api_uri, collection_name, expected_shard_transfer_count)
|
|
except Exception as e:
|
|
print_collection_cluster_info(peer_api_uri, collection_name)
|
|
raise e
|
|
|
|
|
|
def wait_for_collection_local_shards_count(peer_api_uri: str, collection_name: str, expected_local_shard_count: int):
|
|
try:
|
|
wait_for(check_collection_local_shards_count, peer_api_uri, collection_name, expected_local_shard_count)
|
|
except Exception as e:
|
|
print_collection_cluster_info(peer_api_uri, collection_name)
|
|
raise e
|
|
|
|
|
|
def wait_for(condition: Callable[..., bool], *args):
|
|
start = time.time()
|
|
while not condition(*args):
|
|
elapsed = time.time() - start
|
|
if elapsed > WAIT_TIME_SEC:
|
|
raise Exception(
|
|
f"Timeout waiting for condition {condition.__name__} to be satisfied in {WAIT_TIME_SEC} seconds")
|
|
else:
|
|
time.sleep(RETRY_INTERVAL_SEC)
|
|
|
|
|
|
def peer_is_online(peer_api_uri: str) -> bool:
|
|
try:
|
|
r = requests.get(f"{peer_api_uri}")
|
|
return r.status_code == 200
|
|
except:
|
|
return False
|
|
|
|
|
|
def wait_for_peer_online(peer_api_uri: str):
|
|
try:
|
|
wait_for(peer_is_online, peer_api_uri)
|
|
except Exception as e:
|
|
print_clusters_info([peer_api_uri])
|
|
raise e
|
|
|
|
|
|
def wait_collection_on_all_peers(collection_name: str, peer_api_uris: [str], max_wait=30):
|
|
# Check that it exists on all peers
|
|
while True:
|
|
exists = True
|
|
for url in peer_api_uris:
|
|
r = requests.get(f"{url}/collections")
|
|
assert_http_ok(r)
|
|
collections = r.json()["result"]["collections"]
|
|
exists &= any(collection["name"] == collection_name for collection in collections)
|
|
if exists:
|
|
break
|
|
else:
|
|
# Wait until collection is created on all peers
|
|
# Consensus guarantees that collection will appear on majority of peers, but not on all of them
|
|
# So we need to wait a bit extra time
|
|
time.sleep(1)
|
|
max_wait -= 1
|
|
if max_wait <= 0:
|
|
raise Exception("Collection was not created on all peers in time")
|
|
|
|
|
|
def wait_collection_exists_and_active_on_all_peers(collection_name: str, peer_api_uris: [str], max_wait=30):
|
|
wait_collection_on_all_peers(collection_name, peer_api_uris, max_wait)
|
|
for peer_uri in peer_api_uris:
|
|
# Collection is active on all peers
|
|
wait_for_all_replicas_active(collection_name=collection_name, peer_api_uri=peer_uri)
|