Skip to content

Commit ac91295

Browse files
sylwiaszunejkodkropachev
authored andcommitted
Add client routes handler for Private Link support
Add _ClientRoutesHandler which manages the full lifecycle of dynamic address translation via system.client_routes: - initialize(): loads all routes at startup and on control connection reconnect - handle_client_routes_change(): processes CLIENT_ROUTES_CHANGE events with targeted merge or full refresh depending on event data - _query_all_routes_for_connections(): complete refresh query using connection_id IN (...) - _query_routes_for_change_event(): targeted query grouping by connection_id with host_id IN (...) per group - _execute_routes_query(): common query execution and result parsing with proxy address override support - resolve_host(): host_id to (address, port) resolution with DNS lookup
1 parent bc864c1 commit ac91295

1 file changed

Lines changed: 260 additions & 1 deletion

File tree

cassandra/client_routes.py

Lines changed: 260 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,17 @@
2424
from dataclasses import dataclass
2525
import enum
2626
import logging
27+
import socket
2728
import threading
2829
import uuid
29-
from typing import Dict, List, Optional, Set
30+
from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Set, Tuple
31+
32+
from cassandra import ConsistencyLevel
33+
from cassandra.protocol import QueryMessage
34+
from cassandra.query import dict_factory
35+
36+
if TYPE_CHECKING:
37+
from cassandra.connection import Connection
3038

3139
log = logging.getLogger(__name__)
3240

@@ -190,3 +198,254 @@ def merge(self, new_routes: List[_Route], affected_host_ids: Set[uuid.UUID]) ->
190198
if hid not in affected_host_ids}
191199
updated.update(new_by_host)
192200
self._routes_by_host_id = updated
201+
202+
203+
class _ClientRoutesHandler:
204+
"""
205+
Handles dynamic address translation for Private Link via system.client_routes.
206+
207+
Lifecycle:
208+
1. Construction: Create with configuration
209+
2. Initialization: Read system.client_routes after control connection established
210+
3. Steady state: Listen for CLIENT_ROUTES_CHANGE events and update routes
211+
4. Translation: Translate addresses using Host ID lookup
212+
"""
213+
214+
config: 'ClientRoutesConfig'
215+
ssl_enabled: bool
216+
_routes: _RouteStore
217+
_connection_ids: Set[str]
218+
_proxy_addresses_override: Dict[str, str]
219+
220+
def __init__(self, config: 'ClientRoutesConfig', ssl_enabled: bool = False):
221+
"""
222+
:param config: ClientRoutesConfig instance
223+
:param ssl_enabled: Whether TLS is enabled (determines port selection)
224+
"""
225+
if not isinstance(config, ClientRoutesConfig):
226+
raise TypeError("config must be a ClientRoutesConfig instance")
227+
228+
self.config = config
229+
self.ssl_enabled = ssl_enabled
230+
self._routes = _RouteStore()
231+
self._connection_ids = {dep.connection_id for dep in config.proxies}
232+
# Precalculate proxy address mappings for efficient lookup
233+
self._proxy_addresses_override = {
234+
proxy.connection_id: proxy.connection_addr_override
235+
for proxy in config.proxies
236+
if proxy.connection_addr_override
237+
}
238+
239+
def initialize(self, connection: 'Connection', timeout: float) -> None:
240+
"""
241+
Load all routes from system.client_routes.
242+
243+
Called once at startup and again whenever the control connection
244+
is re-established. Reads all configured connection IDs and
245+
replaces the in-memory route store atomically.
246+
247+
Raises on failure so the caller can decide how to react (e.g.
248+
abort startup or schedule a reconnect).
249+
250+
:param connection: The Connection instance to execute queries on
251+
:param timeout: Query timeout in seconds
252+
"""
253+
log.info("[client routes] Loading routes for %d proxies", len(self.config.proxies))
254+
255+
routes = self._query_all_routes_for_connections(connection, timeout, self._connection_ids)
256+
self._routes.update(routes)
257+
258+
def handle_client_routes_change(self, connection: 'Connection', timeout: float,
259+
change_type: 'ClientRoutesChangeType',
260+
connection_ids: Sequence[str], host_ids: Sequence[str]) -> None:
261+
"""
262+
Handle CLIENT_ROUTES_CHANGE event.
263+
264+
Currently the protocol defines only :attr:`ClientRoutesChangeType.UPDATE_NODES`.
265+
New variants will be added to the enum if the protocol is extended.
266+
267+
:param connection: The Connection instance to execute queries on
268+
:param timeout: Query timeout in seconds
269+
:param change_type: A :class:`ClientRoutesChangeType` value
270+
:param connection_ids: Affected connection ID strings; empty means all.
271+
:param host_ids: Affected host ID strings; empty means all.
272+
"""
273+
274+
full_refresh = False
275+
if not connection_ids or not host_ids:
276+
log.warning(
277+
"[client routes] CLIENT_ROUTES_CHANGE has no connection_ids or host_ids, doing full refresh")
278+
full_refresh = True
279+
elif len(connection_ids) != len(host_ids):
280+
log.warning("[client routes] CLIENT_ROUTES_CHANGE has mismatched lengths (conn: %d, host: %d), doing full refresh",
281+
len(connection_ids), len(host_ids))
282+
full_refresh = True
283+
284+
if full_refresh:
285+
routes = self._query_all_routes_for_connections(connection, timeout, self._connection_ids)
286+
self._routes.update(routes)
287+
return
288+
289+
host_uuids = [uuid.UUID(hid) for hid in host_ids]
290+
pairs = [(cid, hid) for cid, hid in zip(connection_ids, host_uuids)
291+
if cid in self._connection_ids]
292+
293+
if not pairs:
294+
return
295+
296+
routes = self._query_routes_for_change_event(connection, timeout, pairs)
297+
self._routes.merge(routes, affected_host_ids=set(host_uuids))
298+
299+
def _query_all_routes_for_connections(self, connection: 'Connection', timeout: float,
300+
connection_ids: Set[str]) -> List[_Route]:
301+
"""
302+
Query all routes for the given connection IDs (complete refresh).
303+
304+
Used when control connection reconnects or as a fallback when
305+
CLIENT_ROUTES_CHANGE event has malformed data.
306+
307+
:param connection: Connection to execute query on
308+
:param timeout: Query timeout in seconds
309+
:param connection_ids: Set of connection ID strings
310+
:return: List of _Route
311+
"""
312+
if not connection_ids:
313+
return []
314+
315+
placeholders = ', '.join('?' for _ in connection_ids)
316+
query = f"SELECT connection_id, host_id, address, port, tls_port FROM system.client_routes WHERE connection_id IN ({placeholders})"
317+
params = [cid.encode('utf-8') for cid in connection_ids]
318+
319+
log.debug("[client routes] Querying all routes for connection_ids=%s", connection_ids)
320+
return self._execute_routes_query(connection, timeout, query, params)
321+
322+
def _query_routes_for_change_event(self, connection: 'Connection', timeout: float,
323+
route_pairs: List[Tuple[str, uuid.UUID]]) -> List[_Route]:
324+
"""
325+
Query specific routes affected by a CLIENT_ROUTES_CHANGE event.
326+
327+
Takes a list of (connection_id, host_id) pairs that represent the exact
328+
routes affected by an operation. This provides precise updates without
329+
fetching unrelated routes.
330+
331+
If the pairs list is empty or None, falls back to a complete refresh
332+
of all routes for safety.
333+
334+
:param connection: Connection to execute query on
335+
:param timeout: Query timeout in seconds
336+
:param route_pairs: List of (connection_id, host_id) tuples
337+
:return: List of _Route
338+
"""
339+
unique_pairs = list(dict.fromkeys(route_pairs))
340+
341+
conn_ids = list(dict.fromkeys(cid for cid, _ in unique_pairs))
342+
host_ids = list(dict.fromkeys(hid for _, hid in unique_pairs))
343+
344+
log.debug("[client routes] Querying route pairs from CLIENT_ROUTES_CHANGE "
345+
"(first 5 of %d): %s", len(unique_pairs), unique_pairs[:5])
346+
347+
conn_ph = ', '.join('?' for _ in conn_ids)
348+
host_ph = ', '.join('?' for _ in host_ids)
349+
query = (
350+
"SELECT connection_id, host_id, address, port, tls_port "
351+
"FROM system.client_routes "
352+
f"WHERE connection_id IN ({conn_ph}) AND host_id IN ({host_ph})"
353+
)
354+
params: List = [cid.encode('utf-8') for cid in conn_ids]
355+
params.extend(hid.bytes for hid in host_ids)
356+
357+
return self._execute_routes_query(connection, timeout, query, params)
358+
359+
def _execute_routes_query(self, connection: 'Connection', timeout: float,
360+
query: str, params: List) -> List[_Route]:
361+
"""
362+
Execute a routes query and parse results.
363+
364+
Common helper for both complete refresh and change event queries.
365+
366+
:param connection: Connection to execute query on
367+
:param timeout: Query timeout in seconds
368+
:param query: CQL query string
369+
:param params: Query parameters
370+
:return: List of _Route
371+
"""
372+
log.debug("[client routes] Executing query: %s with %d parameters", query, len(params))
373+
374+
query_msg = QueryMessage(query=query, consistency_level=ConsistencyLevel.ONE,
375+
query_params=params if params else None)
376+
result = connection.wait_for_response(
377+
query_msg, timeout=timeout
378+
)
379+
380+
routes = []
381+
broken = 0
382+
rows = dict_factory(result.column_names, result.parsed_rows)
383+
for row in rows:
384+
try:
385+
absent = []
386+
port = row['tls_port'] if self.ssl_enabled else row['port']
387+
connection_id = row['connection_id']
388+
host_id = row['host_id']
389+
address = row['address']
390+
391+
if not port:
392+
absent.append("tls_port" if self.ssl_enabled else "port")
393+
if not connection_id:
394+
absent.append("connection_id")
395+
if not host_id:
396+
absent.append("host_id")
397+
if not address:
398+
absent.append("address")
399+
400+
if absent:
401+
log.error("[client routes] read a route %s, that has no values for the following fields: %s", row, ",".join(absent))
402+
broken += 1
403+
continue
404+
405+
final_address = self._proxy_addresses_override.get(connection_id, address)
406+
407+
routes.append(_Route(
408+
connection_id=connection_id,
409+
host_id=host_id,
410+
address=final_address,
411+
port=port,
412+
))
413+
except Exception as e:
414+
log.warning("[client routes] Failed to parse route row: %s", e)
415+
broken += 1
416+
417+
if broken and not routes:
418+
raise RuntimeError(
419+
"[client routes] All %d route rows failed validation; "
420+
"refusing to return empty result that would wipe the route store" % broken
421+
)
422+
423+
return routes
424+
425+
def resolve_host(self, host_id: uuid.UUID) -> Optional[Tuple[str, int]]:
426+
"""
427+
Resolve a host_id to an (address, port) pair.
428+
429+
Looks up the current route and selects the appropriate port.
430+
431+
:param host_id: Host UUID to resolve
432+
:return: Tuple of (address, port) or None if no route mapping exists
433+
"""
434+
route = self._routes.get_by_host_id(host_id)
435+
if route is None:
436+
return None
437+
438+
if not route.port:
439+
raise ValueError("Mapping for host %s has no port" % host_id)
440+
441+
try:
442+
result = socket.getaddrinfo(route.address, route.port,
443+
socket.AF_UNSPEC, socket.SOCK_STREAM)
444+
if not result:
445+
raise socket.gaierror("No addresses found for %s" % route.address)
446+
resolved_ip = result[0][4][0]
447+
return resolved_ip, route.port
448+
except socket.gaierror as e:
449+
log.warning('[client routes] Could not resolve hostname "%s" (host_id=%s): %s',
450+
route.address, host_id, e)
451+
raise

0 commit comments

Comments
 (0)