Skip to content

Implement TABLETS_ROUTING_V2 - #913

Open
dawmd wants to merge 9 commits into
scylladb:masterfrom
dawmd:leader-awareness
Open

Implement TABLETS_ROUTING_V2#913
dawmd wants to merge 9 commits into
scylladb:masterfrom
dawmd:leader-awareness

Conversation

@dawmd

@dawmd dawmd commented Jun 26, 2026

Copy link
Copy Markdown

In dfccfff, we introduced tablet awareness.
Thanks to it, the driver was finally able to learn about the replicas
of a given tablet and successfully route requests to the right nodes
and shards.

Unfortunately, the solution doesn't handle the situation when the
replica set grows properly. Consider the following scenario:

  • A keyspace K uses RF=2.
  • Tablet T in this keyspace has two replicas: A and B.
  • The driver sends requests to T and learns, via TABLETS_ROUTING_V1,
    about the replicas of the tablet. Once that happens, it routes
    subsequent requests to A and B.
  • Then, the user increases the replication factor of K from 2 to 3.
  • It happens that the new replicas of T are: A, B, and C.
  • The driver still routes requests to A and B, and because it always
    hits a replica, it never realises that the replica set has changed.

As a result, C is never targeted by the driver and the load balancing
is suboptimal.


Besides that scenario, the existing algorithm isn't that well suited to
work with strongly consistent tables.

Just like an eventually consistent tablet, a strongly consistent one
has a set of replicas that own it. The difference is that the latter
also has a distinguished replica called the leader that coordinates all
of the writes and (almost) all of the reads to the tablet. That creates
a need for the driver to route its requests to the leader instead of an
arbitrary replica.

Although it's technically possible to adjust the existing tablet
awareness to carry information about the identity of the leader, we
devise a brand new solution that should improve the algorithm.


We introduce the notion of a tablet version -- a 64-bit hash that
corresponds to the replicas of a tablet and (in the case of strongly
consistent tablets) its leader.

With every EXECUTE request, the driver provides information about the
tablet version it knows. If the server detects a mismatch, the response
to the request will contain full routing information: the boundary
tokens of the tablet, the list of its replicas, and the tablet version.
The driver then updates its cache and routes subsequent requests to the
right replicas.


This way, we gain a better control of detecting changes to a tablet.
Furthermore, if the driver routes a request to a seemingly wrong node
because the replicas were unavailable, there is no penalty to it:
routing information will only be returned when the tablet changes
in some way.


Tests have been provided to verify that the implementation is correct.


Fixes: SCYLLADB-291
Refs: SCYLLADB-288

Pre-review checklist

  • I have split my patch into logically separate commits.
  • All commit messages clearly explain what they change and why.
  • I added relevant tests for new features and bug fixes.
  • All commits compile, pass static checks and pass test.
  • PR description sums up the changes and reasons why they should be introduced.
  • I have provided docstrings for the public items that I want to introduce.
  • I have adjusted the documentation in ./docs/source/.
  • I added appropriate Fixes: annotations to PR description.

@dawmd
dawmd requested review from piodul and a balanced review from Copilot June 26, 2026 16:55
@dawmd dawmd self-assigned this Jun 26, 2026
@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The driver adds tablets routing v2 negotiation, tablet-version blocks on prepared executions, connection-level routing, and custom-payload caching. Schema parsing tracks strongly consistent keyspaces, while token-aware plans can prioritize tablet leaders. Unit and integration tests cover protocol behavior, metadata refresh, shard routing, payload decoding, and leader selection.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ResponseFuture
  participant HostConnection
  participant ScyllaServer
  participant ClusterMetadata

  Client->>ResponseFuture: execute prepared query
  ResponseFuture->>HostConnection: borrow connection with routing context
  HostConnection->>ScyllaServer: send EXECUTE with tablet version block
  ScyllaServer-->>ResponseFuture: return tablet routing payload when stale
  ResponseFuture->>ClusterMetadata: cache tablet metadata
Loading

Suggested reviewers: lorak-mmk, wprzytula, mykaul

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely identifies the main change: implementing TABLETS_ROUTING_V2.
Description check ✅ Passed The description explains the motivation, implementation, tests, references, and checklist status; only the documentation checklist remains unchecked.

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements Scylla TABLETS_ROUTING_V2 negotiation and request/response handling in the driver, including encoding the per-request tablet_version_block, parsing the new tablets-routing-v2 custom payload (with tablet_version), and adding leader-aware routing behavior for strongly-consistent keyspaces.

Changes:

  • Add TABLETS_ROUTING_V2 protocol feature negotiation and ensure V2 subsumes V1 in STARTUP options.
  • Attach a per-request tablet_version_block to EXECUTE messages on V2-negotiated connections; parse and cache V2 tablet routing payloads including tablet_version.
  • Introduce strongly-consistent keyspace detection (Scylla-only) and leader-first routing in TokenAwarePolicy; add unit and integration coverage.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tests/unit/test_tablets.py Adds unit tests for tablet_version_block encoding and for storing tablet_version on Tablet.
tests/unit/test_protocol_features.py Adds negotiation tests ensuring V2 is preferred over V1 in STARTUP options.
tests/unit/test_policies.py Adds unit tests for leader-aware routing behavior (leader-first, fallback, and non-SC cases).
tests/integration/standard/test_tablets_routing_v2.py Adds opt-in end-to-end tests validating negotiation, payload decoding, and wire behavior against a V2-capable Scylla cluster.
cassandra/tablets.py Adds tablet_version_block helpers and extends Tablet to store tablet_version.
cassandra/query.py Adds memoization for routing-key token computation via Statement.routing_token().
cassandra/protocol.py Extends ExecuteMessage to optionally append tablet_version_block byte when present.
cassandra/protocol_features.py Adds TABLETS_ROUTING_V2 constant and negotiation logic; makes V2 mutually exclusive with V1 in STARTUP.
cassandra/pool.py Adds per-pool V2 capability detection and reuses memoized routing token to avoid repeated hashing.
cassandra/policies.py Adds leader-first routing for strongly-consistent keyspaces when tablet replicas are available.
cassandra/metadata.py Adds KeyspaceMetadata.strongly_consistent and populates it from Scylla system_schema.scylla_keyspaces.
cassandra/cluster.py Computes/attaches tablet_version_block per connection and parses V2 routing payloads based on the serving connection’s negotiated features.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread cassandra/query.py Outdated
Comment on lines +353 to +357
token = self._routing_token
if token is None:
token = token_class.from_key(routing_key)
self._routing_token = token
return token

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This statement is technically correct:

Theoretically, it would be possible to have either two Cassandra clusters that use different partitioners, or have two Scylla clusters where you have a CDC log table created in the usual way, and the other where the CDC log table is "faked" via CREATE TABLE and thus uses Murmur3. I see that you added the _routing_token_class field but that does not protect us from a case where somebody sends two requests in parallel - routing_token is not protected with a mutex so there is a potential race condition.

I don't know if this is significant enough for us to care. On the other hand, the statement now contains data which is dependent on the context (i.e. the cluster it is sent to) which is something that I think should be avoided in principle. I think it would be better if the token were computed in one place on the statement execution path and then passed manually via e.g. function arguments and not hidden in the Statement class which probably should be immutable.

@sylwiaszunejko What do you think?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be better if the token were computed in one place on the statement execution path and then passed manually via e.g. function arguments and not hidden in the Statement class which probably should be immutable.

That would be ideal if possible, we should do something to avoid potential race condition

@dawmd dawmd Jul 21, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked into it, and it looks like we cannot easily avoid computing the token multiple times.

If we discard the current approach, the only other option I see is computing it early-on and passing on along the control flow. Unfortunately, along the way, we go through make_query_plan, which is part of the public API and so we cannot modify it -- the user is allowed to implement their own LoadBalancingPolicy and override it. The existing arguments don't really allow for hiding the token there, but it's necessary for the function.

However, it might be possible to reduce the number of computations to two. I'll see what can be done about it.

The alternative is to give up on it and accept computing the token three times.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented the two-computation approach in v8.

Comment thread cassandra/pool.py Outdated
Comment thread cassandra/cluster.py Outdated
Comment thread cassandra/cluster.py
Comment thread cassandra/cluster.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (2)
tests/unit/test_policies.py (1)

1043-1082: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: test name/docstring don't match what's actually exercised.

This test asserts the "no tablet_version (V1)" path, but the production code (make_query_plan) never reads tablet.tablet_version; leader-first is gated solely on ks_meta.strongly_consistent, which is set to False here. So the test passes because of the strongly_consistent=False flag, not because tablet_version=None. Consider renaming/reframing it (or setting strongly_consistent=True with tablet_version=None) so the test actually guards the behavior its name implies; otherwise it overlaps with test_no_leader_routing_for_eventually_consistent_keyspace.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_policies.py` around lines 1043 - 1082, The test name and
docstring in test_no_leader_routing_without_tablet_version do not match the
behavior actually being exercised, because make_query_plan only gates
leader-first routing on keyspace metadata strong consistency, not
Tablet.tablet_version. Update the test so it truly covers the intended case by
either renaming/reframing it to reflect strongly_consistent=False, or by setting
cluster.metadata.keyspaces['ks'].strongly_consistent to True while keeping
tablet_version=None to verify the V1 tablet path; keep the assertions aligned
with the TokenAwarePolicy routing behavior.
cassandra/policies.py (1)

510-514: 🚀 Performance & Scalability | 🔵 Trivial

Optional: Cache the child query plan to prevent double invocation and potential inconsistency.

child.make_query_plan(keyspace, query) is invoked at line 512 to filter replicas and is called again at line 551 to build the final plan. If the child policy is stateful (e.g., RoundRobinPolicy or DCAwareRoundRobinPolicy), the second invocation may advance internal pointers and return a different host sequence, causing the filtering logic to diverge from the execution order and introducing unnecessary overhead.

♻️ Suggested approach

Materialize the child plan once and reuse it:

-        if tablet is not None:
-            replicas_mapped = set(map(lambda r: r[0], tablet.replicas))
-            child_plan = child.make_query_plan(keyspace, query)
-
-            replicas = [host for host in child_plan if host.host_id in replicas_mapped]
+        child_plan = list(child.make_query_plan(keyspace, query))
+        if tablet is not None:
+            replicas_mapped = set(map(lambda r: r[0], tablet.replicas))
+            replicas = [host for host in child_plan if host.host_id in replicas_mapped]

Update the subsequent loop (line 551) to iterate over the cached child_plan instead of re-invoking child.make_query_plan(...).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cassandra/policies.py` around lines 510 - 514, Cache the result of
child.make_query_plan(keyspace, query) in the policy logic so it is only
evaluated once, then reuse that same child_plan for both the replica filtering
in the tablet block and the final plan construction loop. Update the query
planning flow in the policy method that handles tablet replicas to iterate over
the cached child_plan instead of calling child.make_query_plan(...) a second
time, which avoids stateful policy divergence and redundant work.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cassandra/cluster.py`:
- Around line 5055-5075: The ExecuteMessage tablet_version_block is only being
set in the pooled connection path, so control-connection fallback can miss the
mandatory V2 trailing byte. Update _query_control_connection() in
cassandra/cluster.py to use the same tablets_routing_v2 check and
_compute_tablet_version_block(self.query) logic as the ExecuteMessage handling
in the main send path, placing it after self._connection = connection and before
connection.send_msg(...) so V2 control connections always include the block and
non-V2 ones do not.
- Around line 5212-5231: The tablet-routing cache is using only
self.query.keyspace when adding tablets, which can store entries under a None
key and miss cache hits for session-level keyspaces. Update the tablet handling
in ResponseFuture to use the effective keyspace consistently, matching
_compute_tablet_version_block() by falling back to self.keyspace when
self.query.keyspace is unset, and then pass that resolved keyspace into
metadata._tablets.add_tablet().

In `@cassandra/metadata.py`:
- Around line 2658-2660: The fallback in metadata schema refresh is too broad
because the except block in the system_schema.scylla_keyspaces read path catches
every Exception, which masks unexpected failures. Narrow the handler around the
code in metadata.py that logs “Could not read system_schema.scylla_keyspaces” so
it only catches the specific expected driver/server schema-unavailable error(s),
and let other refresh errors propagate. Keep the existing debug log and exc_info
behavior for the expected case, but do not use a blanket Exception catch in this
branch.

In `@cassandra/pool.py`:
- Around line 451-453: The tablets_routing_v2 property in Pool should not
iterate the live _connections view directly, because concurrent
replacement/shutdown can mutate it and closed connections can incorrectly keep
V2 enabled. Snapshot the current connections first, then compute the flag only
from still-open/live connections before checking each connection’s
features.tablets_routing_v2.

In `@cassandra/query.py`:
- Line 278: The cached routing token in Statement is being reused without
considering token_class, which can cause the wrong ring/token to be used across
sessions or clusters. Update the token cache logic in Statement-related routing
code so _routing_token is keyed by token_class as well, and make the
lookup/reuse path in the affected routing methods distinguish tokens by the
partitioner class used. Ensure the changes cover the Statement fields and the
routing/token selection flow that currently reads and writes _routing_token.

---

Nitpick comments:
In `@cassandra/policies.py`:
- Around line 510-514: Cache the result of child.make_query_plan(keyspace,
query) in the policy logic so it is only evaluated once, then reuse that same
child_plan for both the replica filtering in the tablet block and the final plan
construction loop. Update the query planning flow in the policy method that
handles tablet replicas to iterate over the cached child_plan instead of calling
child.make_query_plan(...) a second time, which avoids stateful policy
divergence and redundant work.

In `@tests/unit/test_policies.py`:
- Around line 1043-1082: The test name and docstring in
test_no_leader_routing_without_tablet_version do not match the behavior actually
being exercised, because make_query_plan only gates leader-first routing on
keyspace metadata strong consistency, not Tablet.tablet_version. Update the test
so it truly covers the intended case by either renaming/reframing it to reflect
strongly_consistent=False, or by setting
cluster.metadata.keyspaces['ks'].strongly_consistent to True while keeping
tablet_version=None to verify the V1 tablet path; keep the assertions aligned
with the TokenAwarePolicy routing behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 587d3541-27be-4132-8dda-c90e66cff85c

📥 Commits

Reviewing files that changed from the base of the PR and between c1bfd54 and 7cbb8a9.

📒 Files selected for processing (12)
  • cassandra/cluster.py
  • cassandra/metadata.py
  • cassandra/policies.py
  • cassandra/pool.py
  • cassandra/protocol.py
  • cassandra/protocol_features.py
  • cassandra/query.py
  • cassandra/tablets.py
  • tests/integration/standard/test_tablets_routing_v2.py
  • tests/unit/test_policies.py
  • tests/unit/test_protocol_features.py
  • tests/unit/test_tablets.py

Comment thread cassandra/cluster.py Outdated
Comment thread cassandra/cluster.py Outdated
Comment thread cassandra/metadata.py Outdated
Comment thread cassandra/pool.py Outdated
Comment thread cassandra/query.py Outdated
@dawmd

dawmd commented Jul 1, 2026

Copy link
Copy Markdown
Author

v2:

  • Addressed comments.
  • Added documentation.
  • Added tests.
  • Reworked the solution (e.g. we use memoisation for avoiding recomputing the tokens).

Tested against scylladb/scylladb#30291 (scylladb/scylladb@58ab555).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cassandra/metadata.py`:
- Around line 805-810: Update the documentation for the strongly_consistent
metadata field so it matches the behavior in _is_strongly_consistent() and the
new tests. In the keyspace metadata docstring near strongly_consistent, change
the description to state that only ScyllaDB keyspaces with consistency set to
global are marked True, while local, eventual, and non-ScyllaDB cases remain
False. Keep the public contract aligned with the implementation by tightening
the wording around strongly_consistent.

In `@tests/integration/standard/test_tablets_routing_v2.py`:
- Around line 279-294: The global monkeypatch on
ProtocolFeatures.add_startup_options in the test setup can leak if Cluster
creation or shutdown fails. Move the restore logic for add_startup_options into
a nested finally that always runs after the patch is applied, and keep the
cleanup tied to the cluster/session lifecycle in the same test helper around
Cluster.connect and cluster.shutdown. Use the existing
ProtocolFeatures.add_startup_options, Cluster, and session setup block to ensure
the original value is restored no matter which step raises.
- Line 41: The module-level skip logic in the test setup is too broad because
the `except Exception as exc` block can mask unrelated startup problems. Narrow
the handler around the cluster start path to only skip on the specific expected
unsupported-feature/startup failure, using the same setup flow where the cluster
is initialized, and re-raise any other exception so real regressions still fail
fast.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 712ffcf1-f346-4fd7-931d-303a5bc26920

📥 Commits

Reviewing files that changed from the base of the PR and between 7cbb8a9 and c245cbe.

📒 Files selected for processing (12)
  • cassandra/cluster.py
  • cassandra/metadata.py
  • cassandra/policies.py
  • cassandra/pool.py
  • cassandra/query.py
  • cassandra/tablets.py
  • docs/scylla-specific.rst
  • tests/integration/standard/test_tablets_routing_v2.py
  • tests/unit/test_metadata.py
  • tests/unit/test_policies.py
  • tests/unit/test_response_future.py
  • tests/unit/test_tablets.py
🚧 Files skipped from review as they are similar to previous changes (6)
  • tests/unit/test_tablets.py
  • cassandra/policies.py
  • cassandra/query.py
  • tests/unit/test_policies.py
  • cassandra/tablets.py
  • cassandra/cluster.py

Comment thread cassandra/metadata.py Outdated
# advertisement of TABLETS_ROUTING_V2_EXPERIMENTAL.
'experimental_features': ['lwt', 'udf', 'strongly-consistent-tables'],
})
except Exception as exc:

@coderabbitai coderabbitai Bot Jul 1, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find existing integration-test patterns and harness exception types to catch specifically.
rg -n -C3 "def use_cluster|pytest.skip\\(|Could not start a Scylla cluster|except .*Exception" tests cassandra

Repository: scylladb/python-driver

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== tests/integration/standard/test_tablets_routing_v2.py =="
nl -ba tests/integration/standard/test_tablets_routing_v2.py | sed -n '1,120p'

echo
echo "== tests/integration/__init__.py (use_cluster) =="
nl -ba tests/integration/__init__.py | sed -n '421,560p'

echo
echo "== integration test patterns for skip/start failures =="
rg -n -C2 "pytest\.skip\(|Could not start|except .*Exception|DependencyException|NoHostAvailable|OperationTimedOut|FeatureNotSupported|Unsupported" tests/integration tests | sed -n '1,220p'

Repository: scylladb/python-driver

Length of output: 260


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== tests/integration/standard/test_tablets_routing_v2.py =="
sed -n '1,140p' tests/integration/standard/test_tablets_routing_v2.py | cat -n

echo
echo "== tests/integration/__init__.py (use_cluster) =="
sed -n '421,560p' tests/integration/__init__.py | cat -n

echo
echo "== relevant integration skip / exception patterns =="
rg -n -C2 "pytest\.skip\(|Could not start|DependencyException|Unsupported|OperationTimedOut|NoHostAvailable|except .*Exception" tests/integration tests | sed -n '1,220p'

Repository: scylladb/python-driver

Length of output: 32197


Limit the module-level skip to the expected startup failure. Catching Exception here can hide setup bugs, port conflicts, and config regressions behind a skip. Narrow this to the cluster-start/unsupported-feature failure you expect, or re-raise everything else.

🧰 Tools
🪛 Ruff (0.15.20)

[warning] 41-41: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/integration/standard/test_tablets_routing_v2.py` at line 41, The
module-level skip logic in the test setup is too broad because the `except
Exception as exc` block can mask unrelated startup problems. Narrow the handler
around the cluster start path to only skip on the specific expected
unsupported-feature/startup failure, using the same setup flow where the cluster
is initialized, and re-raise any other exception so real regressions still fail
fast.

Source: Linters/SAST tools

✅ Addressed in commits 4753428 to 6133e77

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a valid suggestion. The startup should only be allowed fail due to the strongly-consistent-tables feature being unsupported. Can we detect this?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only did some quick research, but I don't see any way to detect this without inspecting the logs.

Comment thread tests/integration/standard/test_tablets_routing_v2.py
@dawmd
dawmd force-pushed the leader-awareness branch from c245cbe to 8dedad4 Compare July 1, 2026 21:14
@dawmd

dawmd commented Jul 1, 2026

Copy link
Copy Markdown
Author

v2.1:

  • Rebased the PR to resolve a merge conflict.

Also tested against scylladb/scylladb#30291 (scylladb/scylladb@58ab555).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.

Comment thread cassandra/metadata.py Outdated
Comment thread cassandra/tablets.py Outdated
Comment thread cassandra/metadata.py Outdated
Comment thread cassandra/metadata.py
Comment on lines +2590 to +2593
# ScyllaDB-only: per-keyspace consistency option. The column is null for
# eventually-consistent keyspaces (and the whole table is absent on Cassandra
# and on Scylla versions without strongly-consistent tablets).
_SELECT_SCYLLA_KEYSPACES = "SELECT keyspace_name, consistency FROM system_schema.scylla_keyspaces"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a real pity that we need to perform yet another fetch from system_schema in order to obtain this information - especially when strong consistency is still experimental, people will be paying for support for a feature which they not only do not use, but can't use.

Maybe we should have considered sending some information about whether a table uses strong consistency or not in the prepared statement. I think this could also apply to information like the partitioner and whether a table uses tablets or not. This is, of course, out of scope.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. However, if we want to avoid it, we need to modify the server-code before we can merge this PR since we need some way to learn that a table is strongly consistent. I can start working on it in parallel of course.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that modifications like those I propose would require another round of design review. I don't think we should be implementing this at the moment. The other suggestion about skipping the query to scylla_keyspaces should make the current PR palatable enough, we will only pay the cost on experimental clusters with strong consistency enabled.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, we can remove this code for the time being. Unfortunately, the consequence will be ditching all leader awareness from the driver, but it shouldn't be difficult to add it back later on. If that's acceptable, I can proceed with the changes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, the consequence will be ditching all leader awareness from the driver

That would defeat the whole point of this PR.

What I meant by my "we should not be implementing this at the moment" is that we should not be working on further extending the protocol in the way as I suggested in my first message. I did not mean to drop the code that this conversation is attached to (cassandra/metadata.py, lines 2590-2593), we need it to distinguish whether it's a strongly consistent table or not and whether to do leader awareness routing or not.

In #913 (comment) I suggested that we can skip issuing the query if we know that the cluster does not support strong consistency. If we do this, we will not incur the cost for regular users who are not testing strong consistency at the moment. While not great, I don't think an additional metadata query is tragic; we still have some time before release of strong consistency to address it (if there will be a need to address it at all, given the python-over-rust effort).

@dawmd dawmd Jul 8, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gated behind the protocol extension in v3. Leaving the discussion as open since this is still something we might want to improve. It should be easier to remember this way until we create an issue.

Comment thread cassandra/metadata.py
Comment thread cassandra/cluster.py Outdated
Comment thread cassandra/cluster.py Outdated
Comment thread cassandra/cluster.py Outdated
Comment thread cassandra/query.py Outdated
@dawmd
dawmd force-pushed the leader-awareness branch from 8dedad4 to d4c25ca Compare July 8, 2026 22:01

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cassandra/connection.py`:
- Around line 1224-1226: Preserve backward compatibility in
send_msg()/encode_message handling: the new protocol_features argument passed
from self._protocol_handler.encode_message can break custom protocol handler
subclasses that still implement the older signature. Update the call path in
CassandraConnection.send_msg (and any related encode_message wrapper) to either
accept and ignore extra keyword arguments via **kwargs or add a compatibility
shim that only passes protocol_features when the handler supports it, keeping
existing subclasses working.

In `@tests/integration/standard/test_tablets_routing_v2.py`:
- Around line 155-156: The assertion in the tablet routing test is checking the
bound supports_tablet_routing method object instead of its return value. Update
the loop over self.session._pools.values() to invoke supports_tablet_routing()
on each pool before asserting, so the test validates the negotiated feature
rather than the method reference itself.
- Around line 69-77: The setup in the cluster bootstrap path can leak resources
if Cluster.connect() or _create_schema() fails before teardown_class runs. Wrap
the logic in the class setup flow around cls.cluster, cls.session, and
cls._create_schema so any exception triggers immediate cluster shutdown/cleanup
before re-raising, and make sure the cleanup is tied to the existing setup
method that initializes the Cluster and session.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 359bd0fd-f061-419c-83e3-12b654fcc40e

📥 Commits

Reviewing files that changed from the base of the PR and between c245cbe and d4c25ca.

📒 Files selected for processing (16)
  • cassandra/cluster.py
  • cassandra/connection.py
  • cassandra/metadata.py
  • cassandra/policies.py
  • cassandra/pool.py
  • cassandra/protocol.py
  • cassandra/protocol_features.py
  • cassandra/query.py
  • cassandra/tablets.py
  • docs/scylla-specific.rst
  • tests/integration/standard/test_tablets_routing_v2.py
  • tests/unit/test_metadata.py
  • tests/unit/test_policies.py
  • tests/unit/test_protocol_features.py
  • tests/unit/test_response_future.py
  • tests/unit/test_tablets.py
✅ Files skipped from review due to trivial changes (1)
  • docs/scylla-specific.rst
🚧 Files skipped from review as they are similar to previous changes (10)
  • tests/unit/test_protocol_features.py
  • cassandra/tablets.py
  • cassandra/protocol_features.py
  • tests/unit/test_response_future.py
  • tests/unit/test_metadata.py
  • cassandra/query.py
  • tests/unit/test_policies.py
  • cassandra/policies.py
  • cassandra/pool.py
  • tests/unit/test_tablets.py

Comment thread cassandra/connection.py
Comment thread tests/integration/standard/test_tablets_routing_v2.py Outdated
Comment thread tests/integration/standard/test_tablets_routing_v2.py
@dawmd

dawmd commented Jul 8, 2026

Copy link
Copy Markdown
Author

v3:

  • Addressed the feedback:
    • Forced the conversion to uint64_t when receiving the tablet version. Adjusted the tests.
    • Made the field _strongly_consistent private. Updated the description of the field.
    • Gated the query to system_schema.scylla_keyspaces with an if: we only do it if the protocol extension is enabled.
    • Moved away from the cluster feature. The protocol extension is now gated behind the experimental flag. From the perspective of the driver, as long as we use the latest version of Scylla, there should be no difference for testing (as far as I can tell), but I already sent a PR adjusting the server code: transport: Relax advertising TABLETS_ROUTING_V2_EXPRIMENTAL scylladb#30685.
    • Moved the routing token changes to a separate commit. It's mostly an optimisation, but I kept it for now.
    • Avoided copying messages. Instead, we pass protocol_features instead. See the corresponding commits ("Memoize the routing token on Statement" and "Compute and serialize the tablet_version_block per EXECUTE") for details.
  • Introduced an enum to provide at least the most basic typing for consistency modes.
  • Improved the comments.
  • Spread the changes onto more commits. Now we first focus on implementing the "base" TABLETS_ROUTING_V2, and only in the few last commits we extend the code to add leader awareness. This should make the PR easier to review and cleaner in general.
  • Added a few tests.

@dawmd
dawmd force-pushed the leader-awareness branch from d4c25ca to 9c13105 Compare July 13, 2026 14:21
@dawmd

dawmd commented Jul 13, 2026

Copy link
Copy Markdown
Author

v4:

  • Fixed the errors with running new integration tests against clusters that don't support strongly-consistent-tables.

What remains to be fixed is the violation of a public API pointed out by coderabbit here: #913 (comment).

@dawmd

dawmd commented Jul 13, 2026

Copy link
Copy Markdown
Author

The new CI failure seems unrelated to these changes, but I didn't find it in the Issues on GitHub or Jira. Summary of it (since the logs are going to be deleted soon. I have them saved, but let's persist at least this here too):

FAILED tests/integration/standard/test_client_routes.py::TestFullNodeReplacementThroughNlb::test_should_survive_full_node_replacement_through_nlb - urllib.error.HTTPError: HTTP Error 500: Internal Server Error
= 1 failed, 933 passed, 83 skipped, 12 xfailed, 98 warnings in 1092.29s (0:18:12) =
Exception in thread Task Scheduler:
Exception in thread Task Scheduler:
Exception in thread Task Scheduler:
Traceback (most recent call last):
  File "/home/runner/work/_temp/uv-python-dir/cpython-3.13.14-linux-x86_64-gnu/lib/python3.13/threading.py", line 1044, in _bootstrap_inner
    self.run()
    ~~~~~~~~^^
  File "cassandra/cluster.py", line 4664, in cassandra.cluster._Scheduler.run
    future = self._executor.submit(fn, *args, **kwargs)
  File "/home/runner/work/_temp/uv-python-dir/cpython-3.13.14-linux-x86_64-gnu/lib/python3.13/concurrent/futures/thread.py", line 171, in submit
Error:     raise RuntimeError('cannot schedule new futures after shutdown')
RuntimeError: cannot schedule new futures after shutdown
Traceback (most recent call last):
  File "/home/runner/work/_temp/uv-python-dir/cpython-3.13.14-linux-x86_64-gnu/lib/python3.13/threading.py", line 1044, in _bootstrap_inner
    self.run()
    ~~~~~~~~^^
  File "cassandra/cluster.py", line 4664, in cassandra.cluster._Scheduler.run
    future = self._executor.submit(fn, *args, **kwargs)
  File "/home/runner/work/_temp/uv-python-dir/cpython-3.13.14-linux-x86_64-gnu/lib/python3.13/concurrent/futures/thread.py", line 171, in submit
Error:     raise RuntimeError('cannot schedule new futures after shutdown')
RuntimeError: cannot schedule new futures after shutdown
Traceback (most recent call last):
  File "/home/runner/work/_temp/uv-python-dir/cpython-3.13.14-linux-x86_64-gnu/lib/python3.13/threading.py", line 1044, in _bootstrap_inner
    self.run()
    ~~~~~~~~^^
  File "cassandra/cluster.py", line 4664, in cassandra.cluster._Scheduler.run
    future = self._executor.submit(fn, *args, **kwargs)
  File "/home/runner/work/_temp/uv-python-dir/cpython-3.13.14-linux-x86_64-gnu/lib/python3.13/concurrent/futures/thread.py", line 171, in submit
Error:     raise RuntimeError('cannot schedule new futures after shutdown')
RuntimeError: cannot schedule new futures after shutdown
Exception in thread Task Scheduler:
Traceback (most recent call last):
  File "/home/runner/work/_temp/uv-python-dir/cpython-3.13.14-linux-x86_64-gnu/lib/python3.13/threading.py", line 1044, in _bootstrap_inner
    self.run()
    ~~~~~~~~^^
  File "cassandra/cluster.py", line 4664, in cassandra.cluster._Scheduler.run
    future = self._executor.submit(fn, *args, **kwargs)
  File "/home/runner/work/_temp/uv-python-dir/cpython-3.13.14-linux-x86_64-gnu/lib/python3.13/concurrent/futures/thread.py", line 171, in submit
Error:     raise RuntimeError('cannot schedule new futures after shutdown')
RuntimeError: cannot schedule new futures after shutdown
Error: Process completed with exit code 1.

@Lorak-mmk can you take a look and confirm it's unrelated? Then I can create an issue for it.

Btw. I asked Claude to summarise the full logs. This is what it said:

Bottom line

The single failure is unrelated to your PR. It's a Client Routes / Private Link (NLB) test, and your branch doesn't touch that feature or test at all (git diff master...HEAD shows no client_routes/nlb files; none of the 10 commits touch them). The failure is a server-side HTTP 500 from Scylla's REST API, not a driver or tablets-routing problem.

What failed

FAILED test_client_routes.py::TestFullNodeReplacementThroughNlb::test_should_survive_full_node_replacement_through_nlb
  - urllib.error.HTTPError: HTTP Error 500: Internal Server Error
= 1 failed, 933 passed, 83 skipped, 12 xfailed, 98 warnings in 1092.29s =

The test is a 4-stage topology-churn scenario (start 3 nodes → bootstrap 3 → decommission the original 3 → verify session survives). It fails in Stage 4, at test_client_routes.py: right after get_node(node_id).decommission(), it posts the updated route table to a surviving node via test_client_routes.py:

url = "http://%s:10000/v2/client-routes" % contact_point   # Scylla REST API, port 10000
...
response = urllib.request.urlopen(req)   # <-- returns 500

Port 10000 is Scylla's own REST API, so the 500 originates from the Scylla node (release 2026.1.8), not from the test's NLBEmulator (a TCP proxy on other ports) and not from the driver. The POST happens immediately after a decommission() with no retry/settle wait, so the most likely cause is a transient server-side 500 while that node's topology view is mid-change — i.e. a server-side bug or a test-robustness gap in the client-routes suite.

Noise to ignore

  • The ConnectionRefusedError [Errno 111] ... :9042 lines are in Captured log setup during a CCM cluster restart ("topology mismatch"); setup succeeded (the test reached Stage 4), so they're expected boot-time churn.
  • The RuntimeError: cannot schedule new futures after shutdown "Task Scheduler" tracebacks appear after the summary line — post-run shutdown races, not the failure.

@Lorak-mmk

Copy link
Copy Markdown

Doesn't sound like something you could cause. @sylwiaszunejko is a better person to look at this, she implemented PrivateLink tests IIRC.

Copilot AI review requested due to automatic review settings August 3, 2026 20:51
@dawmd
dawmd force-pushed the leader-awareness branch from 5ab766d to fffd456 Compare August 3, 2026 20:51
@dawmd
dawmd marked this pull request as ready for review August 3, 2026 20:52
@dawmd

dawmd commented Aug 3, 2026

Copy link
Copy Markdown
Author

v11:

  • Dropped consistency flag in favour of consistency mode in KeyspaceMetadata.
  • Extended comments (as requested in the review comments).
  • Added a table in the documentation file. It should help visualise the algorithm.
  • Adjusted tests + added two more.
  • Rewrote commit messages.

Btw. I realised some tests might not be passing with partially implemented tablets routing v2, but it turns out that's not the case, so unless I missed something, we should be good.

I didn't touch the load balancing code as the thread is not resolved yet. I hope that's the last thing to change, though.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

Suppressed comments (4)

cassandra/policies.py:548

  • When a statement leaves consistency_level unset, this treats None as leader-requiring. However, Session._create_response_future resolves that same request to the selected execution profile’s consistency level (cluster.py:3017-3032), which is commonly LOCAL_ONE. Those reads will therefore be pinned to the leader despite the documented ONE/LOCAL_ONE carve-out, creating the hotspot this branch is intended to avoid. The effective consistency level needs to be passed into query-plan construction rather than inferred from the raw statement.
                effective_cl = query.consistency_level
                prefer_leader = effective_cl not in (ConsistencyLevel.ONE, ConsistencyLevel.LOCAL_ONE)

tests/integration/standard/test_tablets_routing_v2.py:245

  • None does not omit the byte: ExecuteMessage.send_body coalesces it to 0x00 whenever V2 is negotiated. This docstring currently claims a wire behavior the helper cannot produce.
        Send an EXECUTE directly on a specific shard connection with a chosen
        tablet_version_block (or None to omit the byte entirely, i.e. behave like
        the pre-V2 protocol), and return the decoded response message.

cassandra/policies.py:542

  • Correct the typo in “consistency.”
                # TODO: Figure out how to obtain the actual effective consisteny

cassandra/metadata.py:834

  • Remove the duplicated word.
    The consistency mode of the keyspace, derived from the the ``consistency``
    column ScyllaDB stores in ``system_schema.scylla_keyspaces``.

@dawmd

dawmd commented Aug 4, 2026

Copy link
Copy Markdown
Author

The CI failure is #965. I sent a fix: #966.

dawmd added 5 commits August 4, 2026 14:06
Add per-connection negotiation of the TABLETS_ROUTING_V2 extension, the
successor to TABLETS_ROUTING_V1. When the server advertises it in the
SUPPORTED response, the driver echoes it back during STARTUP to opt in;
a driver that negotiates v2 does not negotiate v1.

While the feature is experimental the wire name carries the
`_EXPERIMENTAL` suffix (TABLETS_ROUTING_V2_EXPERIMENTAL), and the server
only advertises it when started with the `strongly-consistent-tables`
experimental feature enabled.

Also add the trailing tablet_version_block byte to the EXECUTE message
body. The server reads exactly one such byte per EXECUTE on a connection
that negotiated the extension, so the encoder writes one whenever the
connection did -- coalescing an unset value to 0 -- and none otherwise.
Later commits fill in the value from the cached tablet version. Deciding
this from the connection's negotiated features rather than from the
message is what lets one ExecuteMessage be sent, unmodified, on
connections that negotiated differently.
Store the server-provided 64-bit tablet_version on each cached Tablet and
add helpers to encode it into the one-byte tablet_version_block exchanged
on the wire. The version stays None until learned: on a cold start, and on
a TABLETS_ROUTING_V1 connection, which never reports one.

* Tablet.from_row normalizes the version to an unsigned 64-bit value.
  The server sends an unsigned hash, but the driver deserializes the
  payload field as a signed long, so the raw value can come back
  negative; masking to [0, 2**64) keeps the nibble extraction in
  choose_tablet_version_block consistent with the server's unsigned
  layout.
* choose_tablet_version_block() packs a randomly chosen block index in
  the high nibble and that block's value in the low nibble, matching the
  server's locator::compare_tablet_version_block layout. Blocks are
  indexed from the least significant bits, so block i covers bits
  [i*4, i*4 + 4) of the version. A random index avoids any shared mutable
  counter on the hot path while still probing every nibble often enough
  to detect a server-side version change quickly.
* random_tablet_version_block() returns a random byte for cold start,
  when no version is cached yet.
With TABLETS_ROUTING_V2 the server returns, on a tablet_version mismatch,
the tablet's replica set plus the new tablet_version, so the driver can
keep its routing cache fresh without the per-response overhead v1 incurs.

* Every EXECUTE on a V2 connection carries a tablet_version_block
  computed from the cached version -- or a random byte on cold start
  (a token-aware request with no cached version yet), or 0 for a
  non-token-aware request, which the server never version-checks.
* The routing key and its ring token are resolved once per request, in
  _create_response_future, and handed to both consumers that need them
  while sending: the tablet_version_block here and shard selection in
  HostConnection. This keeps cluster-dependent state off the statement,
  which a caller may share between concurrent requests. The cached
  tablet is likewise looked up once -- the cache is mutable, so a
  second lookup could disagree with the first.
* On the response, the routing payload is parsed according to what the
  serving connection negotiated; the v2 tuple additionally carries the
  tablet_version, which is stored back on the tablet. The tablet is
  cached under the effective keyspace -- the statement's, else the
  session's -- so a prepared statement executed in a session keyspace
  lands under the same key the send path looks it up by.
* HostConnection.tablets_routing_v1 becomes supports_tablet_routing:
  shard selection is identical under both versions, since the request
  goes to this host either way and the pool picks the shard this host
  owns for the tablet.

Refs: SCYLLADB-288
Refs: SCYLLADB-291
Cover the end-to-end behaviour against a live ScyllaDB started with the
`strongly-consistent-tables` experimental feature: v2 negotiation,
payload-driven cache population, and the tablet_version_block matching
rules (no payload on a matching block, exactly one matching value per
index, and v2 taking precedence over v1 on a wrong-shard request).

The last of those needs a connection that negotiated both extensions,
which the driver never does on its own, so the test patches
ProtocolFeatures.add_startup_options. The patch delegates to the real
implementation and only adds v1 on top. Enumerating the options itself
would silently stop requesting any extension added later while
ProtocolFeatures still reported it as negotiated -- that is parsed from
SUPPORTED, not from what STARTUP asked for -- and an extension that
changes the frame layout, such as SCYLLA_USE_METADATA_ID, would then
desynchronize every request on the connection.
Extend the "Tablet Awareness" section of the Scylla-specific guide to
cover the V2 protocol extension: the per-connection negotiation and the
tablet_version_block byte that lets the server skip re-sending routing
information the driver already has.
Copilot AI review requested due to automatic review settings August 4, 2026 12:06
@dawmd
dawmd force-pushed the leader-awareness branch from fffd456 to df286f4 Compare August 4, 2026 12:06
@dawmd

dawmd commented Aug 4, 2026

Copy link
Copy Markdown
Author

v12:

  • Rebased on top of the current master to fix CI failures.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

Suppressed comments (4)

cassandra/policies.py:548

  • query.consistency_level is normally None when callers rely on an execution profile; Session._create_response_future resolves the actual level into cl but never writes it back to the statement. Consequently the default LOCAL_ONE read is treated as leader-requiring and every ordinary read is pinned to the leader, contrary to the documented ONE/LOCAL_ONE exception. Please carry the request's resolved consistency level into query-plan generation and add coverage for an unset statement level with a LOCAL_ONE profile.
                effective_cl = query.consistency_level
                prefer_leader = effective_cl not in (ConsistencyLevel.ONE, ConsistencyLevel.LOCAL_ONE)

cassandra/metadata.py:833

  • Remove the duplicated “the.”
    The consistency mode of the keyspace, derived from the the ``consistency``

cassandra/policies.py:542

  • Correct the typo in “consisteny.”
                # TODO: Figure out how to obtain the actual effective consisteny

cassandra/cluster.py:3067

  • This eagerly hashes every routed statement even when no connection supports V2 and no shard-aware pool will consume the token. With the default TokenAwarePolicy, the policy then hashes the same key again (as noted above), so Cassandra/V1 prepared requests regress from one Murmur3 calculation to two plus an unused block lookup/random value. Please gate this work on an active consumer or pass the precomputed token into query-plan generation so it genuinely replaces the other hash.
        routing_key = query.routing_key
        if routing_key is not None:
            token_map = self.cluster.metadata.token_map
            if token_map is not None:
                routing_token = token_map.token_class.from_key(routing_key)

Comment thread cassandra/metadata.py
dawmd added 4 commits August 4, 2026 15:25
Add KeyspaceMetadata._consistency_mode, derived from the per-keyspace
`consistency` option in system_schema.scylla_keyspaces. It is a
_ConsistencyMode enum -- EVENTUAL, LOCAL or GLOBAL -- so the mode the
server reported is kept verbatim instead of being flattened into a
boolean at parse time. The lookup is cached per schema refresh and
degrades to EVENTUAL on non-Scylla clusters, on a control connection that
did not negotiate TABLETS_ROUTING_V2, and on Scylla versions that lack the
table or column.

Scylla only implements `global` so far, so a keyspace's tablets have a
Raft leader exactly when its mode is GLOBAL; `local` is reserved for a
mode that does not exist yet and behaves like `eventual` everywhere.
Callers that care compare against _ConsistencyMode.GLOBAL directly, so
implementing `local` later only widens those comparisons and leaves the
parser and the metadata untouched.

A change of mode also invalidates the keyspace's cached tablets, the same
way a replication-strategy change does: a tablet cached while the
keyspace was eventually consistent carries no leader ordering and must
not survive into a strongly-consistent keyspace, where it would be
misread as a leader hint.

Both names are underscore-prefixed to keep them private: they are not yet
stable and we do not want to commit to a public API for them.
For a strongly-consistent tablet the TABLETS_ROUTING_V2 server orders the
replica set with the Raft leader first (replicas[0]) and keeps it fresh
via the tablet_version already tracked in the previous commits.
TokenAwarePolicy uses this to send reads and writes for such tables
straight to the leader, saving the extra coordinator->leader hop.

The leader is yielded first only when the keyspace's consistency mode is
GLOBAL -- the only mode Scylla implements, and so the only one whose
tablets have a leader -- and when the tablet carries a tablet_version:
eventually-consistent tablet tables are assigned a tablet_version too,
and a versionless (v1-sourced or stale) tablet must not be mistaken for a
leader hint.

Requests at consistency level ONE or LOCAL_ONE are left alone. Any single
replica satisfies them, so preferring the leader would only concentrate
load on it without buying any consistency. The level is read from the
statement, so a request that inherits it from an execution profile looks
unset here and is routed to the leader anyway; that costs a little leader
contention and nothing in correctness, and is tracked separately in
scylladb#953.

The hint stays bounded by the wrapped policy -- the leader is front-run
only if the child policy would consider it at all (never a host it
reports as IGNORED, nor a cross-datacenter leader under a
DCAwareRoundRobinPolicy with no remote hosts); otherwise the usual
token-aware (optionally shuffled) ordering applies and the server
forwards to the leader as it would without v2.

Refs: SCYLLADB-288
Fixes: SCYLLADB-291
Extend the TABLETS_ROUTING_V2 integration suite with a strongly-consistent
(consistency='global', Raft-backed) keyspace and cover, against a live
ScyllaDB: that the driver reads each keyspace's _consistency_mode from
system_schema.scylla_keyspaces (statically, and as keyspaces are created
and dropped), and that TokenAwarePolicy sends a leader-requiring request
for such a table to the Raft leader (replicas[0]).
Extend the Scylla-specific guide's TABLETS_ROUTING_V2 section, which the
previous docs commit introduced for tablet-version tracking, to cover
leader-aware routing: strongly-consistent (Raft-backed) tablet tables have
a leader that the driver targets directly to save the coordinator->leader
hop, the behaviour is best-effort and bounded by the load-balancing
policy, and eventually-consistent tables keep their usual token-aware
ordering.

Include a table of how ScyllaDB serves each operation on such a table, so
the routing distinction has a visible reason: ONE and LOCAL_ONE reads are
non-linearizable and take no Raft read barrier, so they keep normal
token-aware ordering, while QUORUM and LOCAL_QUORUM reads and writes go
through the leader and are routed to it.
@dawmd

dawmd commented Aug 4, 2026

Copy link
Copy Markdown
Author

CI failure:

Stacktrace
[7](https://github.com/scylladb/python-driver/actions/runs/30907682264/job/91986529140?pr=913#step:9:1058)
=================================== FAILURES ===================================
_ TestFullNodeReplacementThroughNlb.test_should_survive_full_node_replacement_through_nlb _

self = <tests.integration.standard.test_client_routes.TestFullNodeReplacementThroughNlb testMethod=test_should_survive_full_node_replacement_through_nlb>

    def test_should_survive_full_node_replacement_through_nlb(self):
        """
        1. Start with 3 nodes behind the NLB
        2. Bootstrap 3 new nodes, add to NLB, update routes
        3. Decommission the original 3 nodes one-by-one, updating NLB/routes
        4. Verify the session survives with only new nodes
        """
        original_node_ids = sorted(self.node_addrs.keys())
        with NLBEmulator(
            node_addresses=self.node_addrs,
        ) as nlb:
            # ---- Stage 1: Set up NLB for initial nodes ----
            log.info("Stage 1: Setting up NLB for %d initial nodes", len(original_node_ids))
    
            post_routes_for_nlb("127.0.0.1", self.connection_id, self.host_id_map, nlb)
            wait_for_routes_visible(self.direct_session, self.connection_id, len(self.host_id_map))
    
            # ---- Stage 2: Create session through NLB ----
            log.info("Stage 2: Creating session through NLB")
            with Cluster(
                contact_points=[NLBEmulator.LISTEN_HOST],
                port=nlb.discovery_port,
                client_routes_config=ClientRoutesConfig(
                    proxies=[ClientRouteProxy(self.connection_id, NLBEmulator.LISTEN_HOST)],
                ),
                load_balancing_policy=RoundRobinPolicy(),
            ) as cluster:
                session = cluster.connect(wait_for_all_pools=True)
                self._assert_query_works(session)
    
                handler = cluster._client_routes_handler
                self.assertIsNotNone(handler)
    
                assert_routes_via_nlb(self, cluster, nlb,
                                         original_node_ids)
                log.info("Stage 2: Session created, all %d nodes via NLB",
                         len(original_node_ids))
    
                # ---- Stage 3: Bootstrap new nodes ----
                new_node_ids = [max(original_node_ids) + 1, max(original_node_ids) + 2, max(original_node_ids) + 3]
                log.info("Stage 3: Adding nodes %s", new_node_ids)
                ccm_cluster = get_cluster()
    
                for node_id in new_node_ids:
                    self._bootstrap_node(ccm_cluster, node_id, data_center='dc1')
    
                expected_total = len(original_node_ids) + len(new_node_ids)
                self._wait_for_condition(
                    lambda: len(cluster.metadata.all_hosts()) >= expected_total,
                    timeout_seconds=60,
                    description="%d nodes in metadata" % expected_total,
                )
    
                for node_id in new_node_ids:
                    nlb.add_node(node_id, "127.0.0.%d" % node_id)
    
                all_host_ids = get_host_ids_from_cluster(session)
                log.info("All host IDs after expansion: %s", all_host_ids)
                post_routes_for_nlb("127.0.0.1", self.connection_id, all_host_ids, nlb)
    
                handler.initialize(
                    cluster.control_connection._connection,
                    cluster.control_connection._timeout)
    
                self._wait_for_condition(
                    lambda: sum(1 for h in cluster.metadata.all_hosts() if h.is_up) >= expected_total,
                    timeout_seconds=60,
                    description="all %d nodes up" % expected_total,
                )
    
                self._assert_query_works(session)
    
                all_node_ids = set(original_node_ids) | set(new_node_ids)
                assert_routes_via_nlb(self, cluster, nlb, all_node_ids)
                log.info("Stage 3: All %d nodes via NLB after expansion",
                         len(all_node_ids))
    
                # ---- Stage 4: Decommission original nodes ----
                log.info("Stage 4: Decommissioning original nodes %s", original_node_ids)
    
                remaining_node_ids = set(all_node_ids)
                remaining_host_ids = dict(all_host_ids)
                for node_id in original_node_ids:
                    log.info("Decommissioning node %d", node_id)
                    get_node(node_id).decommission()
                    nlb.remove_node(node_id)
                    remaining_node_ids.discard(node_id)
    
                    ip = "127.0.0.%d" % node_id
                    remaining_host_ids.pop(ip, None)
    
                    surviving_ips = list(remaining_host_ids.keys())
                    if surviving_ips:
>                       post_routes_for_nlb(
                            surviving_ips[0], self.connection_id,
                            remaining_host_ids, nlb,
                        )

tests/integration/standard/test_client_routes.py:1125: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
tests/integration/standard/test_client_routes.py:288: in post_routes_for_nlb
    post_client_routes(contact_point, routes)
tests/integration/standard/test_client_routes.py:244: in post_client_routes
    response = urllib.request.urlopen(req)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^
../../_temp/uv-python-dir/cpython-3.13.14-linux-x86_64-gnu/lib/python3.13/urllib/request.py:189: in urlopen
    return opener.open(url, data, timeout)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
../../_temp/uv-python-dir/cpython-3.13.14-linux-x86_64-gnu/lib/python3.13/urllib/request.py:495: in open
    response = meth(req, response)
               ^^^^^^^^^^^^^^^^^^^
../../_temp/uv-python-dir/cpython-3.13.14-linux-x86_64-gnu/lib/python3.13/urllib/request.py:604: in http_response
    response = self.parent.error(
../../_temp/uv-python-dir/cpython-3.13.14-linux-x86_64-gnu/lib/python3.13/urllib/request.py:533: in error
    return self._call_chain(*args)
           ^^^^^^^^^^^^^^^^^^^^^^^
../../_temp/uv-python-dir/cpython-3.13.14-linux-x86_64-gnu/lib/python3.13/urllib/request.py:466: in _call_chain
    result = func(*args)
             ^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <urllib.request.HTTPDefaultErrorHandler object at 0x7fdfa0f17b60>
req = <urllib.request.Request object at 0x7fdf94d65950>
fp = <http.client.HTTPResponse object at 0x7fdf9c2bedd0>, code = 500
msg = 'Internal Server Error'
hdrs = <http.client.HTTPMessage object at 0x7fdf9f35e670>

    def http_error_default(self, req, fp, code, msg, hdrs):
>       raise HTTPError(req.full_url, code, msg, hdrs, fp)
E       urllib.error.HTTPError: HTTP Error 500: Internal Server Error

It looks like #931. Unfortunately, the issue doesn't have logs to compare them with these, but its description seems to more or less agree with this stacktrace.

Copilot AI review requested due to automatic review settings August 4, 2026 13:33
@dawmd
dawmd force-pushed the leader-awareness branch from df286f4 to a6039ba Compare August 4, 2026 13:33
@dawmd

dawmd commented Aug 4, 2026

Copy link
Copy Markdown
Author

v13:

  • Included the consistency mode in exported schema. Added tests for it.
  • Updated the cover letter.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

Suppressed comments (2)

cassandra/policies.py:548

  • query.consistency_level is normally None when the execution profile supplies the effective level (the profile default is LOCAL_ONE, and Session._create_response_future resolves it into cl). This therefore treats ordinary default-LOCAL_ONE reads as leader-requiring and pins them to the leader, defeating the documented ONE/LOCAL_ONE load-spreading exception. Pass the already-resolved consistency level into the query-plan decision and cover statements whose consistency level is unset under ONE and LOCAL_ONE profiles.
                effective_cl = query.consistency_level
                prefer_leader = effective_cl not in (ConsistencyLevel.ONE, ConsistencyLevel.LOCAL_ONE)

cassandra/metadata.py:833

  • Remove the duplicated word in this new attribute documentation.
    The consistency mode of the keyspace, derived from the the ``consistency``

@dawmd
dawmd requested a review from Lorak-mmk August 4, 2026 14:01
Comment thread cassandra/cluster.py
Comment thread cassandra/metadata.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants