Skip to content

Commit 3c7e0e8

Browse files
committed
schema-agreement: add session wait API
Add Session.wait_for_schema_agreement() as a session-scoped schema agreement check. The new API queries schema_version from system.local on the connected hosts selected by the requested rack, dc, or cluster scope, respects Cluster.max_schema_agreement_wait and the control-connection metadata timeouts, and bounds the fan-out with configurable parallelism. Update the public Session docs and switch the integration callers that were explicitly waiting on schema agreement to use the session API. Add unit coverage for agreement, retries, busy connections, missing pools, batching, scope filtering, and invalid scope handling.
1 parent 0842348 commit 3c7e0e8

6 files changed

Lines changed: 438 additions & 4 deletions

File tree

cassandra/cluster.py

Lines changed: 161 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from itertools import groupby, count, chain
3030
import json
3131
import logging
32-
from typing import Any, Dict, Optional, Union
32+
from typing import Any, Dict, Optional, Union, Literal
3333
from warnings import warn
3434
from random import random
3535
import re
@@ -3374,6 +3374,166 @@ def pool_finished_setting_keyspace(pool, host_errors):
33743374
for pool in tuple(self._pools.values()):
33753375
pool._set_keyspace_for_all_conns(keyspace, pool_finished_setting_keyspace)
33763376

3377+
def wait_for_schema_agreement(self, wait_time=None, scope: Literal['rack', 'dc', 'cluster']='dc', parallelism: int = 10):
3378+
"""
3379+
Wait for connected hosts in the selected scope to report the same
3380+
schema version from ``system.local``.
3381+
3382+
By default, the timeout for this operation is governed by
3383+
:attr:`~.Cluster.max_schema_agreement_wait` and
3384+
:attr:`~.Cluster.control_connection_timeout`.
3385+
3386+
Passing ``wait_time`` here overrides
3387+
:attr:`~.Cluster.max_schema_agreement_wait`. Setting ``wait_time <= 0``
3388+
will bypass schema agreement waits.
3389+
3390+
``scope`` determines which connected hosts participate in the check.
3391+
Accepted values are ``'rack'``, ``'dc'``, and ``'cluster'``. The
3392+
default ``'dc'`` scope queries connected hosts in the local rack and
3393+
local datacenter. ``'rack'`` narrows the check to connected hosts in
3394+
the local rack only. ``'cluster'`` queries every host this session has
3395+
a live connection pool for, across all datacenters.
3396+
3397+
:param wait_time: Override for
3398+
:attr:`~.Cluster.max_schema_agreement_wait`.
3399+
:param scope: Restricts the check to connected hosts in the local rack,
3400+
local datacenter, or whole connected cluster.
3401+
:returns: ``True`` when the selected connected hosts agree on schema,
3402+
otherwise ``False``.
3403+
:raises ValueError: If ``scope`` is not one of ``'rack'``, ``'dc'``,
3404+
or ``'cluster'``.
3405+
"""
3406+
if scope not in ('rack', 'dc', 'cluster'):
3407+
raise ValueError("Invalid schema agreement scope: %s" % (scope,))
3408+
3409+
total_timeout = wait_time if wait_time is not None else self.cluster.max_schema_agreement_wait
3410+
if total_timeout <= 0:
3411+
return True
3412+
3413+
deadline = time.time() + total_timeout
3414+
schema_mismatches = None
3415+
3416+
while time.time() < deadline:
3417+
schema_mismatches = self._get_schema_mismatches_for_scope(deadline, scope, parallelism)
3418+
if schema_mismatches is None:
3419+
return True
3420+
3421+
log.debug("[session] Local schemas mismatched, trying again")
3422+
remaining = deadline - time.time()
3423+
if remaining > 0:
3424+
time.sleep(min(0.2, remaining))
3425+
3426+
log.warning("Local nodes are reporting a schema disagreement: %s", schema_mismatches)
3427+
return False
3428+
3429+
def _get_schema_mismatches_for_scope(self, deadline, scope: Literal['rack', 'dc', 'cluster'], parallelism):
3430+
hosts = self._get_schema_agreement_hosts(scope)
3431+
versions = defaultdict(set)
3432+
errors = {}
3433+
3434+
if not hosts:
3435+
return {'unavailable': 'No local hosts available'}
3436+
3437+
cl = ConsistencyLevel.ONE
3438+
metadata_request_timeout = self.cluster.control_connection._metadata_request_timeout
3439+
query = QueryMessage(
3440+
query=maybe_add_timeout_to_query(ControlConnection._SELECT_SCHEMA_LOCAL, metadata_request_timeout),
3441+
consistency_level=cl)
3442+
3443+
parallelism = max(1, min(parallelism, len(hosts)))
3444+
for offset in range(0, len(hosts), parallelism):
3445+
remaining = deadline - time.time()
3446+
if remaining <= 0:
3447+
for host in hosts[offset:]:
3448+
errors[host.endpoint] = "Timed out before querying host"
3449+
break
3450+
3451+
futures = {}
3452+
for host in hosts[offset:offset + parallelism]:
3453+
future = self.submit(self._query_local_schema_version, host, query, deadline)
3454+
if future is None:
3455+
errors[host.endpoint] = "Schema agreement executor unavailable"
3456+
continue
3457+
futures[future] = host
3458+
3459+
if not futures:
3460+
continue
3461+
3462+
remaining = deadline - time.time()
3463+
if remaining <= 0:
3464+
for future, host in futures.items():
3465+
future.cancel()
3466+
errors[host.endpoint] = "Timed out before querying host"
3467+
for host in hosts[offset + parallelism:]:
3468+
errors[host.endpoint] = "Timed out before querying host"
3469+
break
3470+
3471+
done, not_done = wait_futures(tuple(futures), timeout=remaining)
3472+
for future in not_done:
3473+
future.cancel()
3474+
host = futures[future]
3475+
errors[host.endpoint] = "Timed out before querying host"
3476+
3477+
for future in done:
3478+
host = futures[future]
3479+
schema_version, error = future.result()
3480+
if error is not None:
3481+
errors[host.endpoint] = error
3482+
continue
3483+
versions[schema_version].add(host.endpoint)
3484+
3485+
if not_done:
3486+
for host in hosts[offset + parallelism:]:
3487+
errors[host.endpoint] = "Timed out before querying host"
3488+
break
3489+
3490+
if len(versions) == 1 and None not in versions and not errors:
3491+
log.debug("[session] Local schemas match")
3492+
return None
3493+
3494+
mismatches = dict((version, list(nodes)) for version, nodes in versions.items())
3495+
if errors:
3496+
mismatches['unavailable'] = dict((endpoint, str(error)) for endpoint, error in errors.items())
3497+
return mismatches
3498+
3499+
def _get_schema_agreement_hosts(self, scope):
3500+
allowed_distances = {
3501+
'rack': (HostDistance.LOCAL_RACK,),
3502+
'dc': (HostDistance.LOCAL_RACK, HostDistance.LOCAL),
3503+
}
3504+
return tuple(
3505+
host for host, pool in tuple(self._pools.items())
3506+
if host.is_up is not False
3507+
and not pool.is_shutdown
3508+
and (scope == 'cluster' or self._profile_manager.distance(host) in allowed_distances[scope]))
3509+
3510+
def _query_local_schema_version(self, host, query, deadline):
3511+
remaining = deadline - time.time()
3512+
if remaining <= 0:
3513+
return None, "Timed out before querying host"
3514+
3515+
pool = self._pools.get(host)
3516+
if not pool or pool.is_shutdown:
3517+
return None, "No active connection pool"
3518+
3519+
try:
3520+
connection = pool._get_connection_for_routing_key()
3521+
query_timeout = self._schema_agreement_query_timeout(remaining)
3522+
local_result = connection.wait_for_response(query, timeout=query_timeout)
3523+
except OperationTimedOut as timeout:
3524+
log.debug("[session] Timed out waiting for schema version from %s: %s", host, timeout)
3525+
return None, timeout
3526+
except (ConnectionException, NoConnectionsAvailable, ConnectionBusy) as exc:
3527+
log.debug("[session] Error querying schema version from %s: %s", host, exc)
3528+
return None, exc
3529+
3530+
rows = dict_factory(local_result.column_names, local_result.parsed_rows)
3531+
return (rows[0].get("schema_version") if rows else None), None
3532+
3533+
def _schema_agreement_query_timeout(self, remaining):
3534+
control_timeout = self.cluster.control_connection._timeout
3535+
return min(control_timeout, remaining) if control_timeout is not None else remaining
3536+
33773537
def user_type_registered(self, keyspace, user_type, klass):
33783538
"""
33793539
Called by the parent Cluster instance when the user registers a new
@@ -4079,7 +4239,6 @@ def _handle_schema_change(self, event):
40794239
self._cluster.scheduler.schedule_unique(delay, self.refresh_schema, **event)
40804240

40814241
def wait_for_schema_agreement(self, connection=None, preloaded_results=None, wait_time=None):
4082-
40834242
total_timeout = wait_time if wait_time is not None else self._cluster.max_schema_agreement_wait
40844243
if total_timeout <= 0:
40854244
return True

docs/api/cassandra/cluster.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ Clusters and Sessions
169169

170170
.. automethod:: set_keyspace(keyspace)
171171

172+
.. automethod:: wait_for_schema_agreement
173+
172174
.. automethod:: get_execution_profile
173175

174176
.. automethod:: execution_profile_clone_update

tests/integration/long/test_schema.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,4 +158,4 @@ def check_and_wait_for_agreement(self, session, rs, exepected):
158158
time.sleep(1)
159159
assert rs.response_future.is_schema_agreed == exepected
160160
if not rs.response_future.is_schema_agreed:
161-
session.cluster.control_connection.wait_for_schema_agreement(wait_time=1000)
161+
session.wait_for_schema_agreement(wait_time=1000)

tests/integration/standard/test_udts.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ def test_can_register_udt_before_connecting(self):
147147
c.register_user_type("udt_test_register_before_connecting2", "user", User2)
148148

149149
s = c.connect(wait_for_all_pools=True)
150-
c.control_connection.wait_for_schema_agreement()
150+
s.wait_for_schema_agreement()
151151

152152
s.execute("INSERT INTO udt_test_register_before_connecting.mytable (a, b) VALUES (%s, %s)", (0, User1(42, 'bob')))
153153
result = s.execute("SELECT b FROM udt_test_register_before_connecting.mytable WHERE a=0")

tests/unit/test_cluster.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
import unittest
15+
from concurrent.futures import Future
1516

1617
import logging
1718
import socket
@@ -23,6 +24,7 @@
2324
InvalidRequest, Unauthorized, AuthenticationFailed, OperationTimedOut, UnsupportedOperation, RequestValidationException, ConfigurationException, ProtocolVersion
2425
from cassandra.cluster import _Scheduler, Session, Cluster, default_lbp_factory, \
2526
ExecutionProfile, _ConfigMode, EXEC_PROFILE_DEFAULT
27+
from cassandra.connection import ConnectionBusy
2628
from cassandra.pool import Host
2729
from cassandra.policies import HostDistance, RetryPolicy, RoundRobinPolicy, DowngradingConsistencyRetryPolicy, SimpleConvictionPolicy
2830
from cassandra.query import SimpleStatement, named_tuple_factory, tuple_factory
@@ -247,11 +249,65 @@ def test_event_delay_timing(self, *_):
247249

248250

249251
class SessionTest(unittest.TestCase):
252+
class FakeTime(object):
253+
254+
def __init__(self):
255+
self.clock = 0
256+
257+
def time(self):
258+
return self.clock
259+
260+
def sleep(self, amount):
261+
self.clock += amount
262+
263+
class MockPool(object):
264+
265+
def __init__(self, host, connection):
266+
self.host = host
267+
self.host_distance = HostDistance.LOCAL
268+
self.is_shutdown = False
269+
self.connection = connection
270+
271+
def _get_connection_for_routing_key(self):
272+
return self.connection
273+
250274
def setUp(self):
251275
if connection_class is None:
252276
raise unittest.SkipTest('libev does not appear to be installed correctly')
253277
connection_class.initialize_reactor()
254278

279+
def _mock_schema_response(self, schema_version):
280+
response = Mock()
281+
response.column_names = ["schema_version"]
282+
response.parsed_rows = [[schema_version]]
283+
return response
284+
285+
def _new_schema_agreement_session(self, schema_versions, distances=None):
286+
hosts = []
287+
distance_map = {}
288+
if distances is None:
289+
distances = [HostDistance.LOCAL] * len(schema_versions)
290+
291+
for index, schema_version in enumerate(schema_versions):
292+
host = Host("127.0.0.%d" % (index + 1), SimpleConvictionPolicy, host_id=uuid.uuid4())
293+
host.set_up()
294+
hosts.append(host)
295+
distance_map[host] = distances[index]
296+
297+
cluster = Cluster(protocol_version=4)
298+
for host in hosts:
299+
cluster.metadata.add_or_return_host(host)
300+
301+
session = Session(cluster, hosts)
302+
session._profile_manager.distance = Mock(side_effect=lambda host: distance_map.get(host, HostDistance.LOCAL))
303+
session._pools = {}
304+
for host, schema_version in zip(hosts, schema_versions):
305+
connection = Mock(endpoint=host.endpoint)
306+
connection.wait_for_response.return_value = self._mock_schema_response(schema_version)
307+
session._pools[host] = self.MockPool(host, connection)
308+
309+
return session, hosts
310+
255311
# TODO: this suite could be expanded; for now just adding a test covering a PR
256312
@mock_session_pools
257313
def test_default_serial_consistency_level_ep(self, *_):
@@ -339,6 +395,106 @@ def test_set_keyspace_escapes_quotes(self, *_):
339395
assert query == 'USE simple_ks', (
340396
"Simple keyspace names should not be quoted, got: %r" % query)
341397

398+
@mock_session_pools
399+
def test_wait_for_schema_agreement_queries_all_local_hosts(self, *_):
400+
session, hosts = self._new_schema_agreement_session(["a", "a"])
401+
402+
assert session.wait_for_schema_agreement(wait_time=1)
403+
404+
for host in hosts:
405+
connection = session._pools[host].connection
406+
connection.wait_for_response.assert_called_once()
407+
408+
@mock_session_pools
409+
def test_wait_for_schema_agreement_retries_until_local_hosts_match(self, *_):
410+
session, hosts = self._new_schema_agreement_session(["a", "b"])
411+
clock = self.FakeTime()
412+
second_connection = session._pools[hosts[1]].connection
413+
second_connection.wait_for_response.side_effect = [
414+
self._mock_schema_response("b"),
415+
self._mock_schema_response("a")]
416+
417+
with patch('cassandra.cluster.time', new=clock):
418+
assert session.wait_for_schema_agreement(wait_time=1)
419+
assert second_connection.wait_for_response.call_count == 2
420+
assert clock.clock == 0.2
421+
422+
@mock_session_pools
423+
def test_wait_for_schema_agreement_retries_when_local_connection_is_busy(self, *_):
424+
session, hosts = self._new_schema_agreement_session(["a", "a"])
425+
clock = self.FakeTime()
426+
busy_connection = session._pools[hosts[1]].connection
427+
busy_connection.wait_for_response.side_effect = [
428+
ConnectionBusy("connection overloaded"),
429+
self._mock_schema_response("a")]
430+
431+
with patch('cassandra.cluster.time', new=clock):
432+
assert session.wait_for_schema_agreement(wait_time=1)
433+
assert busy_connection.wait_for_response.call_count == 2
434+
assert clock.clock == 0.2
435+
436+
@mock_session_pools
437+
def test_wait_for_schema_agreement_ignores_local_hosts_without_session_pool(self, *_):
438+
session, hosts = self._new_schema_agreement_session(["a"])
439+
440+
unconnected_host = Host("127.0.0.2", SimpleConvictionPolicy, host_id=uuid.uuid4())
441+
unconnected_host.set_up()
442+
session.cluster.metadata.add_or_return_host(unconnected_host)
443+
444+
assert session.wait_for_schema_agreement(wait_time=1)
445+
session._pools[hosts[0]].connection.wait_for_response.assert_called()
446+
447+
@mock_session_pools
448+
@patch('cassandra.cluster.wait_futures')
449+
def test_wait_for_schema_agreement_limits_parallel_queries_to_default(self, mocked_wait_futures, *_):
450+
session, _ = self._new_schema_agreement_session(["a"] * 11)
451+
batch_sizes = []
452+
453+
def submit(fn, host, query, deadline):
454+
future = Future()
455+
future.set_result(fn(host, query, deadline))
456+
return future
457+
458+
def fake_wait(futures, timeout=None, return_when=None):
459+
batch_sizes.append(len(futures))
460+
return set(futures), set()
461+
462+
session.submit = Mock(side_effect=submit)
463+
mocked_wait_futures.side_effect = fake_wait
464+
465+
assert session.wait_for_schema_agreement(wait_time=1)
466+
assert batch_sizes == [10, 1]
467+
468+
@mock_session_pools
469+
def test_wait_for_schema_agreement_rack_scope_only_queries_local_rack_connections(self, *_):
470+
session, hosts = self._new_schema_agreement_session(
471+
["a", "a", "a"],
472+
distances=[HostDistance.LOCAL_RACK, HostDistance.LOCAL, HostDistance.REMOTE])
473+
474+
assert session.wait_for_schema_agreement(wait_time=1, scope='rack')
475+
476+
session._pools[hosts[0]].connection.wait_for_response.assert_called_once()
477+
session._pools[hosts[1]].connection.wait_for_response.assert_not_called()
478+
session._pools[hosts[2]].connection.wait_for_response.assert_not_called()
479+
480+
@mock_session_pools
481+
def test_wait_for_schema_agreement_cluster_scope_queries_all_connected_hosts(self, *_):
482+
session, hosts = self._new_schema_agreement_session(
483+
["a", "a", "a"],
484+
distances=[HostDistance.LOCAL_RACK, HostDistance.LOCAL, HostDistance.REMOTE])
485+
486+
assert session.wait_for_schema_agreement(wait_time=1, scope='cluster')
487+
488+
for host in hosts:
489+
session._pools[host].connection.wait_for_response.assert_called_once()
490+
491+
@mock_session_pools
492+
def test_wait_for_schema_agreement_rejects_unknown_scope(self, *_):
493+
session, _ = self._new_schema_agreement_session(["a"])
494+
495+
with pytest.raises(ValueError):
496+
session.wait_for_schema_agreement(wait_time=1, scope='planet')
497+
342498
class ProtocolVersionTests(unittest.TestCase):
343499

344500
def test_protocol_downgrade_test(self):

0 commit comments

Comments
 (0)