mirror of
https://github.com/qdrant/qdrant.git
synced 2026-09-29 01:17:56 -05:00
* test: add a gated proxy for peer RPCs Pause one selected internal request while other peer traffic continues. Preserve payloads, metadata, deadlines, and cancellation so consensus tests can control transfer timing without blocking unrelated requests. Cover forwarding, independent gates, and cleanup with socket tests. * test: connect peer proxies to consensus clusters Let consensus tests route internal RPCs through request gates. Keep each proxy alive across peer restarts so advertised addresses remain stable, and close all proxies during test cleanup. Wait for the upstream gRPC connection before returning from proxied startup. Verify consensus progress during a held WAL-delta request, recovery data, and restart behavior with both URI configuration modes. * test: fix potentially misleading peer proxy method names Explicitly state the guarantees, or lack of. Signed-off-by: Anton Antonov <anton.synd.antonov@gmail.com> * test: add support for hold_snapshot_download Removes flakiness from snapshot-related consensus tests too Signed-off-by: Anton Antonov <anton.synd.antonov@gmail.com> * test: improve asserts when force deleting peer Actually verify survivors recover and retain the expected data. Making sure no data loss happens. Signed-off-by: Anton Antonov <anton.synd.antonov@gmail.com> * test: add OsError socket handling + explicit wal_delta tests Signed-off-by: Anton Antonov <anton.synd.antonov@gmail.com> * test: reject zero as a defined consensus leader * test: recheck leader agreement on each poll After a restart, the leader can change during election. Let the cluster wait resample the leader on each poll and require agreement on a nonzero leader before the snapshot test starts its transfer. Keep explicit leader checks for existing callers, membership-size checks, and the existing timeout. Cover election changes and offline peers. * test: verify independent snapshot download gates * test: use a positive peer connection deadline * test: cover recovery after the removed source exits * chore: add clarifying comment on timeout=0 usage It's not obvious at first why it's like so. Signed-off-by: Anton Antonov <anton.synd.antonov@gmail.com> * test: share consensus response gates Move response gates, their tests, and Raft decoding from the leader removal proof into the base test infrastructure. Both removal scenarios can then use the same successful-response check. * test: support selective RPC blocking Keep a removed source unaware of membership changes while its transfer continues. Block its Raft traffic in both directions so election attempts cannot disrupt survivor recovery. * test: make source removal scenarios deterministic Separate recovery after source exit from late data sent by a removed source. Require a successful receiver response in the late scenario, and retain complete data and replica-state checks in both cases. * test: refactor timeouts and deadlines * Cancellation happens after observing the intended phase, without an RPC deadline. * Separate deadline tests cover held requests, upstream work, and held responses. Signed-off-by: Anton Antonov <anton.synd.antonov@gmail.com> * test: bound peer probes and removal requests Give cluster probes and peer removal finite client timeouts so a stalled HTTP request cannot leave the test waiting indefinitely. * test: separate RPC release from termination Keep the upstream handler blocked until the test releases it or the RPC terminates. Use a separate termination event for cancellation assertions, and release the handler during teardown instead of racing a fixture timer. * test: use monotonic polling deadlines Measure elapsed polling time with a monotonic clock so system clock adjustments cannot shorten or extend the wait. * test: allow more time to observe proxy events Allow ten seconds for proxy observations and ordinary test requests. Event and future waits still return as soon as they complete. Keep the one-second expiry tests and document the HTTP deadline setup race. * test: bound leader and replication requests Limit how long leader lookup and transfer submission wait for an HTTP response. A stalled submission must fail so the test can release its transfer gates and clean up the peers. * test: preserve readiness failures in diagnostics Catch request failures while collecting cluster diagnostics, including read timeouts. Report the original readiness failure instead of replacing it with a diagnostic error. * test: assert points calls for the correct collection Signed-off-by: Anton Antonov <anton.synd.antonov@gmail.com> * test: make sure check_cluster_size and check_leader cannot stall Have an explicit timeout. Signed-off-by: Anton Antonov <anton.synd.antonov@gmail.com> * test: retry timeouts during initial leader lookup Treat request timeouts as retryable while discovering the expected leader, matching the subsequent leader and membership checks. Keep polling after a transient timeout instead of aborting the cluster-status wait. * test: ensure batch data is different Signed-off-by: Anton Antonov <anton.synd.antonov@gmail.com> --------- Signed-off-by: Anton Antonov <anton.synd.antonov@gmail.com>
436 lines
18 KiB
Python
436 lines
18 KiB
Python
"""A test-only peer proxy with gates for RPCs and snapshot downloads.
|
|
|
|
The proxy forwards protobuf bytes unchanged. A gate holds one matching request
|
|
before forwarding it. Later requests, including calls to the same method, pass
|
|
through so recovery traffic can continue. Matchers run on the server loop and
|
|
must not block. They can decode the bytes to select a collection or shard.
|
|
Response gates hold one reply after the upstream handler returns.
|
|
|
|
RPC bodies may omit the source peer. Tests must establish which peer
|
|
sends the selected request. HTTP gates select the full source URL and intercept
|
|
downloads made by the peer configured with this proxy's environment.
|
|
|
|
Only unary gRPC and bodyless HTTP GETs to local peers are supported, not
|
|
streaming RPCs, HTTPS, or a general-purpose HTTP proxy.
|
|
"""
|
|
|
|
import asyncio
|
|
from concurrent.futures import Future
|
|
from concurrent.futures import TimeoutError as FutureTimeoutError
|
|
from contextlib import contextmanager
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
import logging
|
|
import select
|
|
import socket
|
|
from threading import Event, Lock, Thread
|
|
from typing import Callable
|
|
from urllib.parse import quote, urlsplit
|
|
|
|
import grpc
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
# Snapshot consumers can pause while unpacking or writing data to disk.
|
|
RELAY_TIMEOUT = 30
|
|
|
|
|
|
class RequestGate:
|
|
def __init__(self, proxy, key, matches):
|
|
self._key = key
|
|
self.cancelled = Event()
|
|
self._proxy = proxy
|
|
self._matches = matches
|
|
self._arrived = Future()
|
|
self._released = Future()
|
|
|
|
def wait_for_request(self, timeout: float = 30):
|
|
"""Return the RPC bytes or download URL once the gate is reached.
|
|
|
|
Response gates raise if the upstream call fails or is cancelled.
|
|
|
|
With timeout=0, return an already-arrived request or raise TimeoutError
|
|
immediately. This checks the current state without waiting for an
|
|
in-flight request.
|
|
"""
|
|
try:
|
|
return self._arrived.result(timeout)
|
|
except FutureTimeoutError as error:
|
|
raise TimeoutError(f"No request reached the gate for {self._key[1]}") from error
|
|
|
|
def release(self):
|
|
"""Release the held request, or remove a gate that has not been reached."""
|
|
with self._proxy._lock:
|
|
if self._proxy._gates.get(self._key) is self:
|
|
del self._proxy._gates[self._key]
|
|
self._arrived.cancel()
|
|
if not self._released.done():
|
|
self._released.set_result(None)
|
|
|
|
|
|
class PeerProxy(grpc.GenericRpcHandler):
|
|
"""Own the peer's RPC and HTTP handlers, gates, and cleanup.
|
|
|
|
`target` is the real peer's `host:port`. Advertise `uri` as the peer address
|
|
when wiring a cluster through the proxy. Port zero lets the OS reserve a
|
|
free port without racing pytest workers.
|
|
|
|
Pass `env` to the peer so its outgoing snapshot downloads use `http_uri`.
|
|
Use as a context manager. Closing cancels held and forwarded requests and
|
|
joins both listeners and their request handlers. One gate can wait for each
|
|
RPC method or download URL. Later matching requests pass through.
|
|
|
|
With the peer configured to advertise the proxy URI, a test can use:
|
|
|
|
with proxy.hold_rpc("/qdrant.CollectionsInternal/GetShardRecoveryPoint") as gate:
|
|
replicate_shard(...)
|
|
request = gate.wait_for_request()
|
|
# Check the transfer identity, then remove or stop the source.
|
|
gate.release()
|
|
|
|
Reaching this gate proves that the RPC was sent, not that any data was
|
|
copied. The test must separately check membership, shard state, and data.
|
|
"""
|
|
|
|
def __init__(self, target: str, port: int = 0):
|
|
self._target = target
|
|
self._port = port
|
|
self._gates = {}
|
|
self._blocked_rpcs = {}
|
|
self._lock = Lock()
|
|
self._http_connections = set()
|
|
self._closed = Event()
|
|
self._ready = Future()
|
|
self._thread = Thread(target=self._run, name="consensus-peer-proxy", daemon=True)
|
|
self._thread.start()
|
|
try:
|
|
self.port = self._ready.result(10)
|
|
except BaseException:
|
|
self._thread.join(timeout=10)
|
|
raise
|
|
self.address = f"127.0.0.1:{self.port}"
|
|
self.uri = f"http://{self.address}"
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_):
|
|
self.close()
|
|
|
|
def wait_for_peer_connection(self, timeout: float = 30):
|
|
"""Wait for the internal gRPC connection, including after a restart.
|
|
|
|
This does not check peer health, consensus progress, or replica state.
|
|
"""
|
|
try:
|
|
self._submit(self._channel.channel_ready(), timeout=timeout)
|
|
except FutureTimeoutError as error:
|
|
raise TimeoutError(
|
|
f"Proxy did not establish a gRPC connection to {self._target} within {timeout} seconds"
|
|
) from error
|
|
|
|
def close(self):
|
|
self._closed.set()
|
|
# Interrupt slow writes instead of waiting for the longer relay timeout.
|
|
with self._lock:
|
|
for connection in self._http_connections:
|
|
try:
|
|
connection.shutdown(socket.SHUT_RDWR)
|
|
except OSError:
|
|
pass
|
|
if self._thread.is_alive():
|
|
self._loop.call_soon_threadsafe(self._stop.set)
|
|
self._thread.join(timeout=10)
|
|
if self._thread.is_alive():
|
|
raise TimeoutError("Peer proxy did not stop")
|
|
|
|
def hold_rpc(self, method: str, matches: Callable[[bytes], bool] = lambda _: True):
|
|
"""Hold one matching RPC before the peer receives it."""
|
|
if not method.startswith("/") or method.count("/") != 2:
|
|
raise ValueError("Use the full gRPC method path: /service/method")
|
|
return self._hold(("rpc", method), matches)
|
|
|
|
def hold_rpc_response(self, method: str, matches: Callable[[bytes], bool] = lambda _: True):
|
|
"""Hold one successful response after forwarding the matching request.
|
|
|
|
Matchers receive request bytes. Background work may still be running.
|
|
"""
|
|
if not method.startswith("/") or method.count("/") != 2:
|
|
raise ValueError("Use the full gRPC method path: /service/method")
|
|
return self._hold(("rpc_response", method), matches)
|
|
|
|
def hold_snapshot_download(self, source_uri: str, collection: str, shard_id: int):
|
|
"""Hold this peer's download from the given source, collection, and shard.
|
|
|
|
For a shard transfer, the receiver has already cleared its old shard
|
|
when this request arrives. User-triggered URL recovery does not clear it.
|
|
"""
|
|
source = _local_http_url(source_uri)
|
|
if source.path not in ("", "/") or source.query:
|
|
raise ValueError("Use the source peer's base HTTP URI without a path or query")
|
|
url = f"{source_uri.rstrip('/')}/collections/{quote(collection, safe='')}/shards/{shard_id}/snapshot"
|
|
return self._hold(("http", url), lambda _: True)
|
|
|
|
@contextmanager
|
|
def block_rpc(self, method: str, matches: Callable[[bytes], bool] = lambda _: True):
|
|
"""Reject every matching call to this method until the context exits.
|
|
|
|
Other methods stay live, so a test can separate consensus delivery from
|
|
an in-flight shard transfer on the same peer.
|
|
"""
|
|
with self._lock:
|
|
if method in self._blocked_rpcs:
|
|
raise RuntimeError(f"RPC is already blocked: {method}")
|
|
self._blocked_rpcs[method] = matches
|
|
try:
|
|
yield
|
|
finally:
|
|
with self._lock:
|
|
del self._blocked_rpcs[method]
|
|
|
|
@contextmanager
|
|
def _hold(self, key, matches):
|
|
with self._lock:
|
|
if self._closed.is_set():
|
|
raise RuntimeError("Peer proxy is closed")
|
|
if key in self._gates:
|
|
raise RuntimeError(f"A gate is already waiting for {key[1]}")
|
|
gate = RequestGate(self, key, matches)
|
|
self._gates[key] = gate
|
|
try:
|
|
yield gate
|
|
finally:
|
|
gate.release()
|
|
|
|
def _take_gate_and_notify(self, key, request):
|
|
"""Remove and return the matching gate, notifying the test of arrival."""
|
|
gate = self._take_gate(key, request)
|
|
if gate is not None:
|
|
gate._arrived.set_result(request)
|
|
return gate
|
|
|
|
def _take_gate(self, key, request):
|
|
"""Remove and return the matching gate without notifying the test.
|
|
|
|
Response gates notify only after the upstream response arrives.
|
|
"""
|
|
with self._lock:
|
|
gate = self._gates.get(key)
|
|
if gate is not None and gate._matches(request):
|
|
# Another peer's recovery must not wait behind this request.
|
|
del self._gates[key]
|
|
return gate
|
|
return None
|
|
|
|
def _submit(self, coroutine, timeout: float = 10):
|
|
if not self._thread.is_alive():
|
|
coroutine.close()
|
|
raise RuntimeError("Peer proxy is closed")
|
|
future = asyncio.run_coroutine_threadsafe(coroutine, self._loop)
|
|
try:
|
|
return future.result(timeout)
|
|
except FutureTimeoutError:
|
|
future.cancel()
|
|
raise
|
|
|
|
def _run(self):
|
|
try:
|
|
asyncio.run(self._serve())
|
|
except BaseException as error:
|
|
if not self._ready.done():
|
|
self._ready.set_exception(error)
|
|
else:
|
|
raise
|
|
|
|
async def _serve(self):
|
|
self._loop = asyncio.get_running_loop()
|
|
self._stop = asyncio.Event()
|
|
# Shard batches and Raft snapshots can exceed gRPC's default 4 MiB.
|
|
options = (
|
|
("grpc.max_receive_message_length", -1),
|
|
("grpc.max_send_message_length", -1),
|
|
("grpc.so_reuseport", 0),
|
|
)
|
|
self._channel = grpc.aio.insecure_channel(self._target, options=options)
|
|
self._server = grpc.aio.server(handlers=(self,), options=options)
|
|
http_server = None
|
|
http_thread = None
|
|
try:
|
|
port = self._server.add_insecure_port(f"127.0.0.1:{self._port}")
|
|
await self._server.start()
|
|
http_server = ThreadingHTTPServer(("127.0.0.1", 0), _DownloadHandler)
|
|
# Cleanup must join the HTTP request handlers as well as the listener.
|
|
http_server.daemon_threads = False
|
|
http_server.proxy = self
|
|
self.http_port = http_server.server_port
|
|
self.http_uri = f"http://127.0.0.1:{self.http_port}"
|
|
# Clear inherited bypass rules so local downloads use this peer's gate.
|
|
self.env = {"http_proxy": self.http_uri, "HTTP_PROXY": self.http_uri, "no_proxy": "", "NO_PROXY": ""}
|
|
http_thread = Thread(target=http_server.serve_forever, kwargs={"poll_interval": 0.05})
|
|
http_thread.start()
|
|
self._ready.set_result(port)
|
|
await self._stop.wait()
|
|
finally:
|
|
self._closed.set()
|
|
with self._lock:
|
|
for gate in self._gates.values():
|
|
gate._arrived.cancel()
|
|
self._gates.clear()
|
|
await self._server.stop(0)
|
|
await self._channel.close()
|
|
if http_server is not None:
|
|
if http_thread is not None:
|
|
http_server.shutdown()
|
|
http_server.server_close()
|
|
if http_thread is not None:
|
|
http_thread.join()
|
|
|
|
def service(self, handler_call_details):
|
|
async def forward(request, context):
|
|
with self._lock:
|
|
matches = self._blocked_rpcs.get(handler_call_details.method)
|
|
blocked = matches is not None and matches(request)
|
|
if blocked:
|
|
await context.abort(grpc.StatusCode.UNAVAILABLE, "RPC blocked by test")
|
|
gate = self._take_gate_and_notify(("rpc", handler_call_details.method), request)
|
|
if gate is not None:
|
|
try:
|
|
# RPC cancellation must not cancel the shared release future.
|
|
await asyncio.shield(asyncio.wrap_future(gate._released))
|
|
except asyncio.CancelledError:
|
|
gate.cancelled.set()
|
|
raise
|
|
|
|
response_gate = self._take_gate(
|
|
("rpc_response", handler_call_details.method), request,
|
|
)
|
|
call = self._channel.unary_unary(handler_call_details.method)(
|
|
request,
|
|
metadata=context.invocation_metadata(),
|
|
timeout=context.time_remaining(),
|
|
)
|
|
try:
|
|
await context.send_initial_metadata(await call.initial_metadata())
|
|
response = await call
|
|
if response_gate is not None:
|
|
response_gate._arrived.set_result(request)
|
|
await asyncio.shield(asyncio.wrap_future(response_gate._released))
|
|
context.set_trailing_metadata(await call.trailing_metadata())
|
|
return response
|
|
except asyncio.CancelledError:
|
|
if response_gate is not None:
|
|
response_gate.cancelled.set()
|
|
raise
|
|
except grpc.aio.AioRpcError as error:
|
|
if response_gate is not None:
|
|
if error.code() in (grpc.StatusCode.CANCELLED, grpc.StatusCode.DEADLINE_EXCEEDED):
|
|
response_gate.cancelled.set()
|
|
if not response_gate._arrived.done():
|
|
response_gate._arrived.set_exception(error)
|
|
await context.abort(error.code(), error.details(), tuple(error.trailing_metadata()))
|
|
finally:
|
|
if response_gate is not None:
|
|
# Removed gates are no longer in the proxy's cleanup list.
|
|
response_gate._arrived.cancel()
|
|
# A disconnected caller must not leave work running upstream.
|
|
call.cancel()
|
|
|
|
return grpc.unary_unary_rpc_method_handler(forward)
|
|
|
|
|
|
def _local_http_url(url):
|
|
parsed = urlsplit(url)
|
|
if (parsed.scheme != "http" or parsed.hostname != "127.0.0.1" or not parsed.port
|
|
or parsed.username is not None or parsed.password is not None or parsed.fragment):
|
|
raise ValueError("Use an HTTP URL with an explicit port on 127.0.0.1")
|
|
return parsed
|
|
|
|
|
|
class _DownloadHandler(BaseHTTPRequestHandler):
|
|
timeout = 5
|
|
|
|
def log_message(self, *_):
|
|
pass
|
|
|
|
def do_GET(self):
|
|
proxy = self.server.proxy
|
|
try:
|
|
target = _local_http_url(self.path)
|
|
except ValueError as error:
|
|
self.send_error(400, str(error))
|
|
return
|
|
if self.headers.get("Transfer-Encoding") or self.headers.get("Content-Length", "0") != "0":
|
|
self.send_error(400, "Only bodyless snapshot GETs are supported")
|
|
return
|
|
if target.port == proxy.http_port:
|
|
self.send_error(400, "Cannot forward a download back to this proxy")
|
|
return
|
|
|
|
gate = proxy._take_gate_and_notify(("http", self.path), self.path)
|
|
if gate is not None:
|
|
while not gate._released.done():
|
|
if proxy._closed.is_set() or self._client_disconnected():
|
|
gate.cancelled.set()
|
|
return
|
|
try:
|
|
gate._released.result(timeout=0.05)
|
|
except FutureTimeoutError:
|
|
pass
|
|
if proxy._closed.is_set() or self._client_disconnected():
|
|
gate.cancelled.set()
|
|
return
|
|
|
|
with proxy._lock:
|
|
if proxy._closed.is_set():
|
|
return
|
|
proxy._http_connections.add(self.connection)
|
|
response_started = False
|
|
try:
|
|
with socket.create_connection((target.hostname, target.port), timeout=self.timeout) as upstream:
|
|
path = target.path or "/"
|
|
if target.query:
|
|
path += "?" + target.query
|
|
connection_headers = {
|
|
name.strip().lower() for name in self.headers.get("Connection", "").split(",")
|
|
}
|
|
connection_headers.update({"connection", "proxy-connection", "proxy-authorization", "host"})
|
|
headers = "".join(
|
|
f"{name}: {value}\r\n" for name, value in self.headers.items()
|
|
if name.lower() not in connection_headers
|
|
)
|
|
upstream.sendall(
|
|
f"GET {path} HTTP/1.1\r\nHost: {target.netloc}\r\n{headers}Connection: close\r\n\r\n".encode("latin-1")
|
|
)
|
|
self.connection.settimeout(RELAY_TIMEOUT)
|
|
# Relay bytes unchanged, including chunked snapshot framing.
|
|
# Watch the caller so cancellation also closes a stalled download.
|
|
while not proxy._closed.is_set():
|
|
readable, _, _ = select.select([upstream, self.connection], [], [], 0.05)
|
|
if self.connection in readable:
|
|
return
|
|
if upstream in readable:
|
|
data = upstream.recv(64 * 1024)
|
|
if not data:
|
|
return
|
|
# sendall can transmit some bytes before raising an error.
|
|
response_started = True
|
|
self.connection.sendall(data)
|
|
except OSError as error:
|
|
# socket.timeout is an OSError too. Once output may have reached the
|
|
# caller, close the transfer without appending another HTTP response.
|
|
if response_started:
|
|
logger.warning("Snapshot transfer truncated while relaying %s: %s", self.path, error)
|
|
else:
|
|
logger.warning("Snapshot request failed before response output for %s: %s", self.path, error)
|
|
try:
|
|
self.send_error(502, "Snapshot source request failed")
|
|
except OSError:
|
|
pass
|
|
finally:
|
|
self.close_connection = True
|
|
with proxy._lock:
|
|
proxy._http_connections.discard(self.connection)
|
|
|
|
def _client_disconnected(self):
|
|
readable, _, _ = select.select([self.connection], [], [], 0)
|
|
return bool(readable)
|