Skip to content

Commit bc864c1

Browse files
sylwiaszunejkodkropachev
authored andcommitted
Add client routes data types and route store
Introduce the data layer for Private Link client routes support: - ClientRoutesChangeType enum for CLIENT_ROUTES_CHANGE event types - ClientRouteProxy dataclass and ClientRoutesConfig for user-facing configuration - _Route frozen dataclass for immutable route records - _RouteStore for thread-safe route storage with atomic update/merge and preferred route selection that avoids unnecessary connection_id migration when multiple routes exist for the same host
1 parent 8bba6eb commit bc864c1

1 file changed

Lines changed: 192 additions & 0 deletions

File tree

cassandra/client_routes.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
# Copyright 2026 ScyllaDB, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
Client Routes support for Private Link and similar network configurations.
17+
18+
This module implements support for dynamic address translation via the
19+
system.client_routes table and CLIENT_ROUTES_CHANGE events.
20+
"""
21+
22+
from __future__ import absolute_import
23+
24+
from dataclasses import dataclass
25+
import enum
26+
import logging
27+
import threading
28+
import uuid
29+
from typing import Dict, List, Optional, Set
30+
31+
log = logging.getLogger(__name__)
32+
33+
34+
class ClientRoutesChangeType(enum.Enum):
35+
"""
36+
Types of CLIENT_ROUTES_CHANGE events.
37+
38+
Currently the protocol defines only UPDATE_NODES.
39+
New variants will be added here if the protocol is extended.
40+
"""
41+
UPDATE_NODES = "UPDATE_NODES"
42+
43+
44+
@dataclass
45+
class ClientRouteProxy:
46+
"""
47+
:param connection_id: String identifying the connection (required)
48+
:param connection_addr_override:: Optional string address for initial connection
49+
"""
50+
51+
connection_id: str
52+
connection_addr_override: Optional[str] = None
53+
54+
def __post_init__(self):
55+
if self.connection_id is None:
56+
raise ValueError("connection_id is required")
57+
58+
class ClientRoutesConfig:
59+
"""
60+
Configuration for client routes (Private Link support).
61+
62+
:param proxies: List of :class:`ClientRouteProxy` objects
63+
(REQUIRED, at least one)
64+
:param advanced_shard_awareness: Whether to enable advanced shard awareness
65+
(default: ``False``)
66+
"""
67+
68+
proxies: List[ClientRouteProxy]
69+
advanced_shard_awareness: bool
70+
71+
def __init__(self, proxies: List[ClientRouteProxy], advanced_shard_awareness: bool = False):
72+
"""
73+
:param proxies: List of ClientRouteProxy objects
74+
:param advanced_shard_awareness: Enable advanced shard awareness (default False)
75+
"""
76+
if not proxies:
77+
raise ValueError("At least one proxy must be specified")
78+
79+
if not isinstance(proxies, (list, tuple)):
80+
raise TypeError("proxies must be a list or tuple")
81+
82+
for proxy in proxies:
83+
if not isinstance(proxy, ClientRouteProxy):
84+
raise TypeError("All proxies must be ClientRouteProxy instances")
85+
86+
self.proxies = proxies
87+
self.advanced_shard_awareness = advanced_shard_awareness
88+
89+
def __repr__(self) -> str:
90+
return (f"ClientRoutesConfig(proxies={self.proxies}, "
91+
f"advanced_shard_awareness={self.advanced_shard_awareness})")
92+
93+
94+
@dataclass(frozen=True)
95+
class _Route:
96+
connection_id: str
97+
host_id: uuid.UUID
98+
address: str # ipv4, ipv6 or DNS hostname from system.client_routes
99+
port: int
100+
101+
class _RouteStore:
102+
"""
103+
Thread-safe storage for routes. Reads are safe under CPython's GIL;
104+
writes are serialized with a lock.
105+
106+
This uses atomic pointer swaps for updates, allowing lock-free reads
107+
while serializing writes.
108+
"""
109+
110+
_routes_by_host_id: Dict[uuid.UUID, _Route]
111+
_lock: threading.Lock
112+
113+
def __init__(self) -> None:
114+
self._routes_by_host_id = {}
115+
self._lock = threading.Lock()
116+
117+
def get_by_host_id(self, host_id: uuid.UUID) -> Optional[_Route]:
118+
"""
119+
Get route for a host ID (lock-free read).
120+
121+
:param host_id: UUID of the host
122+
:return: _Route or None
123+
"""
124+
return self._routes_by_host_id.get(host_id)
125+
126+
def get_all(self) -> List[_Route]:
127+
"""
128+
Get all routes as a list (lock-free read).
129+
130+
:return: List of _Route
131+
"""
132+
return list(self._routes_by_host_id.values())
133+
134+
def _select_preferred_routes(self, new_routes: List[_Route]) -> List[_Route]:
135+
"""
136+
When multiple routes exist for the same host_id (different connection_ids),
137+
prefer the connection_id already in use. Only migrate to a different
138+
connection_id when the previously used one is no longer available.
139+
140+
Must be called under self._lock.
141+
"""
142+
by_host: Dict[uuid.UUID, List[_Route]] = {}
143+
for route in new_routes:
144+
by_host.setdefault(route.host_id, []).append(route)
145+
146+
selected = []
147+
for host_id, candidates in by_host.items():
148+
if len(candidates) == 1:
149+
selected.append(candidates[0])
150+
continue
151+
152+
existing = self._routes_by_host_id.get(host_id)
153+
if existing:
154+
preferred = [c for c in candidates if c.connection_id == existing.connection_id]
155+
if preferred:
156+
selected.append(preferred[0])
157+
continue
158+
159+
selected.append(candidates[0])
160+
161+
return selected
162+
163+
def update(self, routes: List[_Route]) -> None:
164+
"""
165+
Replace all routes atomically.
166+
167+
:param routes: List of _Route objects
168+
"""
169+
with self._lock:
170+
preferred = self._select_preferred_routes(routes)
171+
self._routes_by_host_id = {route.host_id: route for route in preferred}
172+
173+
def merge(self, new_routes: List[_Route], affected_host_ids: Set[uuid.UUID]) -> None:
174+
"""
175+
Merge new routes with existing ones atomically.
176+
177+
Routes for affected_host_ids are replaced entirely: existing routes
178+
for those hosts are dropped and replaced with whatever is in new_routes.
179+
This handles deletions from system.client_routes (affected host present
180+
but no new route for it).
181+
182+
:param new_routes: List of _Route objects to merge
183+
:param affected_host_ids: Set of host IDs affected by the change.
184+
"""
185+
with self._lock:
186+
preferred = self._select_preferred_routes(new_routes)
187+
new_by_host = {r.host_id: r for r in preferred}
188+
189+
updated = {hid: r for hid, r in self._routes_by_host_id.items()
190+
if hid not in affected_host_ids}
191+
updated.update(new_by_host)
192+
self._routes_by_host_id = updated

0 commit comments

Comments
 (0)