Files
qdrant/tests/consensus_tests/test_replicate_points.py
Andrey Vasnetsov bc7207b230 test(consensus): de-flake replicate_points_stream_transfer_updates override case (#9755)
* 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>
2026-07-09 15:04:01 +02:00

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