mirror of
https://github.com/qdrant/qdrant.git
synced 2026-07-26 12:41:04 -05:00
* test(consensus): de-flake replicate_points_stream_transfer_updates override case With override_points=True the background writer re-upserts points 9990-9999, re-rolling their city payload. Points flipping away from "London" legitimately drop out of the filtered count on both shards, so asserting dest_filtered_count >= original snapshot count is not a valid invariant. On a slow CI runner the sleep(1)+kill() stopped the writer right after the overrides, before new inserts could compensate, making a net-negative flip likely (observed: 4954 >= 4959 failure). Replace the blind kill with a bounded workload (60 points) joined cleanly before the ~10s transfer of 10k points can finish, assert the writer exit code, and allow the filtered count to drop by up to the number of overridden points. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(consensus): guard that writer finishes while transfer is running The exact count consistency check requires every concurrent write to go through the transfer proxy. Make that precondition explicit: if the transfer ever finishes before the writer, fail with a clear message instead of a confusing count mismatch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(consensus): make replicate_points update consistency checks exact Assign the city payload deterministically by point ID parity so filter membership can never change under concurrent overwrites. All assertions become exact ID-set comparisons with no slack: random city re-rolls made count-based checks unsound, since the forward proxy filters forwarded updates by post-update state and a point flipping out of the filter legitimately goes stale or missing on the destination. Replace the background writer process (sleep/kill/join choreography) with synchronous wait=true upserts issued while the transfer streams the initial points. Leave low point IDs unoccupied and insert into them during the transfer: the stream cursor passes them immediately, so these points can only reach the destination through live update forwarding, which the previous layout (writes at the stream tail) never verified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
237 lines
9.6 KiB
Python
237 lines
9.6 KiB
Python
import pathlib
|
|
|
|
from .fixtures import upsert_random_points, create_collection, random_dense_vector, random_sparse_vector
|
|
from .utils import *
|
|
|
|
from .test_custom_sharding import create_shard
|
|
|
|
N_PEERS = 1
|
|
N_SHARDS = 1
|
|
N_REPLICA = 1
|
|
COLLECTION_NAME = "test_collection"
|
|
|
|
|
|
def city_by_id(point_id):
|
|
# City is a pure function of the point ID, so the set of points matching
|
|
# a city filter is known exactly at any moment, no matter how points are
|
|
# overwritten concurrently.
|
|
return "London" if point_id % 2 == 0 else "Berlin"
|
|
|
|
|
|
def upsert_points_by_id(peer_url, ids, collection_name=COLLECTION_NAME, shard_key=None, batch_size=5):
|
|
for batch_start in range(0, len(ids), batch_size):
|
|
batch = ids[batch_start:batch_start + batch_size]
|
|
r = requests.put(
|
|
f"{peer_url}/collections/{collection_name}/points?wait=true",
|
|
json={
|
|
"points": [
|
|
{
|
|
"id": point_id,
|
|
"vector": {
|
|
"": random_dense_vector(),
|
|
"sparse-text": random_sparse_vector(),
|
|
},
|
|
"payload": {"city": city_by_id(point_id)},
|
|
}
|
|
for point_id in batch
|
|
],
|
|
"shard_key": shard_key,
|
|
},
|
|
)
|
|
assert_http_ok(r)
|
|
|
|
|
|
def scroll_all_ids(peer_url, collection_name, shard_key, filter=None):
|
|
ids = set()
|
|
offset = None
|
|
while True:
|
|
r = requests.post(
|
|
f"{peer_url}/collections/{collection_name}/points/scroll",
|
|
json={
|
|
"limit": 10000,
|
|
"offset": offset,
|
|
"with_payload": False,
|
|
"with_vector": False,
|
|
"shard_key": shard_key,
|
|
"filter": filter,
|
|
},
|
|
)
|
|
assert_http_ok(r)
|
|
result = r.json()["result"]
|
|
ids.update(point["id"] for point in result["points"])
|
|
offset = result["next_page_offset"]
|
|
if offset is None:
|
|
return ids
|
|
|
|
|
|
# Transfer points from one shard to another
|
|
#
|
|
# Simply does the most basic transfer: no concurrent updates during the
|
|
# transfer.
|
|
#
|
|
# Test that data on the both sides is consistent
|
|
def test_replicate_points_stream_transfer(tmp_path: pathlib.Path):
|
|
assert_project_root()
|
|
|
|
# seed port to reuse the same port for the restarted nodes
|
|
peer_api_uris, peer_dirs, bootstrap_uri = start_cluster(tmp_path, N_PEERS)
|
|
|
|
create_collection(
|
|
peer_api_uris[0],
|
|
sharding_method="custom",
|
|
shard_number=N_SHARDS,
|
|
replication_factor=N_REPLICA,
|
|
)
|
|
wait_collection_exists_and_active_on_all_peers(
|
|
collection_name=COLLECTION_NAME, peer_api_uris=peer_api_uris
|
|
)
|
|
|
|
create_shard(
|
|
peer_api_uris[0], COLLECTION_NAME, shard_key="default", shard_number=N_SHARDS
|
|
)
|
|
create_shard(
|
|
peer_api_uris[0], COLLECTION_NAME, shard_key="tenant", shard_number=N_SHARDS
|
|
)
|
|
|
|
# Insert some initial number of points
|
|
upsert_random_points(peer_api_uris[0], 100, shard_key="default")
|
|
|
|
filter = {"must": {"key": "city", "match": {"value": "London"}}}
|
|
inverse_filter = {"must_not": {"key": "city", "match": {"value": "London"}}}
|
|
|
|
expected_count = get_collection_point_count(peer_api_uris[0], COLLECTION_NAME, shard_key="default", exact=True, filter=filter)
|
|
initial_count = get_collection_point_count(peer_api_uris[0], COLLECTION_NAME, shard_key="tenant", exact=True)
|
|
assert expected_count > 0
|
|
assert initial_count == 0 # no points in tenant shard key before transfer
|
|
|
|
r = requests.post(
|
|
f"{peer_api_uris[0]}/collections/{COLLECTION_NAME}/cluster",
|
|
json={
|
|
"replicate_points": {
|
|
"filter": filter,
|
|
"from_shard_key": "default",
|
|
"to_shard_key": "tenant",
|
|
}
|
|
},
|
|
)
|
|
assert_http_ok(r)
|
|
|
|
# Wait for end of shard transfer
|
|
wait_for_collection_shard_transfers_count(peer_api_uris[0], COLLECTION_NAME, 0)
|
|
|
|
receiver_collection_cluster_info = get_collection_cluster_info(
|
|
peer_api_uris[-1], COLLECTION_NAME
|
|
)
|
|
number_local_shards = len(receiver_collection_cluster_info["local_shards"])
|
|
assert number_local_shards == 2
|
|
|
|
# Point counts must be consistent across shard keys for given filter
|
|
default_filtered_count = get_collection_point_count(peer_api_uris[0], COLLECTION_NAME, shard_key="default", exact=True, filter=filter)
|
|
tenant_count = get_collection_point_count(peer_api_uris[0], COLLECTION_NAME, shard_key="tenant", exact=True)
|
|
tenant_inverse_count = get_collection_point_count(peer_api_uris[0], COLLECTION_NAME, shard_key="tenant", exact=True, filter=inverse_filter)
|
|
assert default_filtered_count == expected_count # original shard should remain unchanged
|
|
assert tenant_count == expected_count # new shard should also have the points
|
|
assert tenant_inverse_count == 0 # new shard should have only the points matching the filter
|
|
|
|
# Replicate points from one shard to another while applying updates in parallel
|
|
#
|
|
# Points are assigned a city deterministically by ID parity, so the exact set
|
|
# of points matching the city filter is known at all times and all assertions
|
|
# are exact set comparisons: no slack for concurrent filter membership changes
|
|
# is needed.
|
|
#
|
|
# The transfer streams the source shard in ascending point ID order. Initial
|
|
# points occupy IDs [100, 10100), leaving low IDs unoccupied: points inserted
|
|
# at IDs [0, 30) during the transfer are behind the stream cursor and can only
|
|
# reach the destination through live update forwarding, so this test fails if
|
|
# forwarding silently stops working. Overridden and appended points cover the
|
|
# update-to-streamed-point and insert-ahead-of-cursor paths respectively.
|
|
@pytest.mark.parametrize("override_points", [True, False])
|
|
def test_replicate_points_stream_transfer_updates(tmp_path: pathlib.Path, override_points: bool):
|
|
assert_project_root()
|
|
|
|
# seed port to reuse the same port for the restarted nodes
|
|
peer_api_uris, peer_dirs, bootstrap_uri = start_cluster(tmp_path, N_PEERS)
|
|
|
|
create_collection(
|
|
peer_api_uris[0],
|
|
sharding_method="custom",
|
|
shard_number=N_SHARDS,
|
|
replication_factor=N_REPLICA,
|
|
indexing_threshold=None,
|
|
)
|
|
wait_collection_exists_and_active_on_all_peers(
|
|
collection_name=COLLECTION_NAME, peer_api_uris=peer_api_uris
|
|
)
|
|
|
|
create_shard(
|
|
peer_api_uris[0], COLLECTION_NAME, shard_key="default", shard_number=N_SHARDS
|
|
)
|
|
create_shard(
|
|
peer_api_uris[0], COLLECTION_NAME, shard_key="tenant", shard_number=N_SHARDS
|
|
)
|
|
|
|
# Insert initial points in a single request, like a regular bulk upload
|
|
initial_ids = list(range(100, 10100))
|
|
upsert_points_by_id(peer_api_uris[0], initial_ids, shard_key="default", batch_size=len(initial_ids))
|
|
|
|
filter = {"must": {"key": "city", "match": {"value": "London"}}}
|
|
|
|
assert get_collection_point_count(peer_api_uris[0], COLLECTION_NAME, shard_key="tenant", exact=True) == 0
|
|
|
|
r = requests.post(
|
|
f"{peer_api_uris[0]}/collections/{COLLECTION_NAME}/cluster",
|
|
json={
|
|
"replicate_points": {
|
|
"filter": filter,
|
|
"from_shard_key": "default",
|
|
"to_shard_key": "tenant",
|
|
}
|
|
},
|
|
)
|
|
assert_http_ok(r)
|
|
|
|
# Concurrent updates, applied synchronously with wait=true while the
|
|
# transfer streams the 10k initial points:
|
|
# - overrides at the stream head: the stream passes IDs 100..109 within the
|
|
# first batch, so these updates reach the destination through forwarding
|
|
# - inserts below the stream cursor: deliverable only through forwarding
|
|
# - inserts above the stream cursor: delivered by the stream or forwarding
|
|
override_ids = list(range(100, 110)) if override_points else []
|
|
low_insert_ids = list(range(0, 30))
|
|
high_insert_ids = list(range(10100, 10130))
|
|
written_ids = override_ids + low_insert_ids + high_insert_ids
|
|
upsert_points_by_id(peer_api_uris[0], written_ids, shard_key="default")
|
|
|
|
# Precondition for the exact assertions below: every write above must have
|
|
# gone through the transfer proxy. A handful of small requests cannot
|
|
# realistically outlast streaming 10k points on the same node; if this
|
|
# ever fires, the test did not exercise concurrent updates at all.
|
|
assert get_shard_transfer_count(peer_api_uris[0], COLLECTION_NAME) > 0, \
|
|
"transfer finished before the concurrent writes completed; increase the initial point count"
|
|
|
|
# Wait for end of transfer
|
|
wait_for_collection_shard_transfers_count(peer_api_uris[0], COLLECTION_NAME, 0)
|
|
|
|
receiver_collection_cluster_info = get_collection_cluster_info(
|
|
peer_api_uris[-1], COLLECTION_NAME
|
|
)
|
|
number_local_shards = len(receiver_collection_cluster_info["local_shards"])
|
|
assert number_local_shards == 2
|
|
|
|
all_ids = set(initial_ids) | set(written_ids)
|
|
expected_london_ids = {point_id for point_id in all_ids if city_by_id(point_id) == "London"}
|
|
|
|
src_ids = scroll_all_ids(peer_api_uris[0], COLLECTION_NAME, shard_key="default")
|
|
src_london_ids = scroll_all_ids(peer_api_uris[0], COLLECTION_NAME, shard_key="default", filter=filter)
|
|
dest_ids = scroll_all_ids(peer_api_uris[0], COLLECTION_NAME, shard_key="tenant")
|
|
|
|
# Source contains every written point, and its filtered view is exact
|
|
assert src_ids == all_ids
|
|
assert src_london_ids == expected_london_ids
|
|
|
|
# Destination contains exactly the matching points and nothing else:
|
|
# no point lost (streamed, forwarded override, forwarded low-ID insert),
|
|
# no non-matching point leaked through the stream or forwarding
|
|
assert dest_ids == expected_london_ids
|