Skip to content

This PR fixes UNPREPARED handling in the Python driver execution path… - #725

Open
calebxyz wants to merge 1 commit into
masterfrom
scylladb-27657-v2
Open

calebxyz wants to merge 1 commit into
masterfrom
scylladb-27657-v2

Conversation

@calebxyz

@calebxyz calebxyz commented Mar 4, 2026 •

Copy link
Copy Markdown

Problem

When the server returned UNPREPARED (PreparedQueryNotFound) during an EXECUTE, the driver could fail to recover in some valid cases (cache miss, race, coordinator restart, id mismatch edge cases or crushes), even when the in-flight request still had enough PreparedStatement context to recover.

Expected behavior

If the driver still has the original PreparedStatement context, it should:

  1. issue PREPARE,
  2. then retry the original EXECUTE, regardless of why the coordinator no longer recognizes the prepared id.

What this PR changes

In ResponseFuture PreparedQueryNotFound handling:

  1. Cache the in-flight self.prepared_statement first (when present).
  2. Attempt lookup by the returned UNPREPARED id from cluster._prepared_statements.
  3. Reprepare using the resolved statement.
  4. If returned-id lookup fails:
    • fallback to in-flight context if available,
    • otherwise fail as unknown prepared statement.

This preserves correctness while improving recovery in real-world coordinator invalidation/race/restart scenarios.

Tests

Added/updated unit coverage in tests/unit/test_response_future.py:

  • validates reprepare path for PreparedQueryNotFound,
  • validates fallback to in-flight context,
  • validates preference for statement resolved by returned UNPREPARED id when available.

Fixes: scylladb/scylladb#27657

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.

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

This PR improves the Python driver’s recovery behavior when an EXECUTE receives an UNPREPARED (PreparedQueryNotFound) error, allowing the driver to re-PREPARE and retry using either the returned query id (preferred) or the in-flight prepared statement context (fallback).

Changes:

  • Update ResponseFuture._set_result() to cache in-flight prepared_statement, resolve by returned UNPREPARED id when possible, and fall back to in-flight context when not.
  • Adjust logging for unknown-prepared and mismatch/fallback scenarios.
  • Add unit tests covering fallback to local context and preference for returned-id cache resolution.

Reviewed changes

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

File Description
cassandra/cluster.py Updates UNPREPARED handling in ResponseFuture._set_result() to improve reprepare/retry correctness across cache-miss and id-mismatch scenarios.
tests/unit/test_response_future.py Adds unit coverage for the new UNPREPARED resolution and fallback behavior.

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

You can also share your feedback on Copilot code review. Take the survey.

Comment thread cassandra/cluster.py Outdated
Comment thread cassandra/cluster.py Outdated
@Lorak-mmk
Lorak-mmk self-requested a review March 4, 2026 10:36
@calebxyz
calebxyz force-pushed the scylladb-27657-v2 branch from 0f3737f to 8ed0715 Compare March 4, 2026 10:36
Comment thread cassandra/cluster.py Fixed
@calebxyz
calebxyz force-pushed the scylladb-27657-v2 branch 3 times, most recently from 3d3dc24 to 9815e0f Compare March 4, 2026 10:48
Comment thread tests/unit/test_response_future.py
Comment thread cassandra/cluster.py Outdated
Comment on lines +4716 to +4752
@@ -4745,11 +4743,13 @@ def _set_result(self, host, connection, pool, response):
(current_keyspace, prepared_keyspace)))
return

log.debug("Re-preparing unrecognized prepared statement against host %s: %s",
host, prepared_statement.query_string)
prepared_keyspace = prepared_statement.keyspace \
log.debug(
"Re-preparing unrecognized prepared statement against host %s: %s",
host, self.prepared_statement.query_string
)
prepared_keyspace = self.prepared_statement.keyspace \
if ProtocolVersion.uses_keyspace_flag(self.session.cluster.protocol_version) else None
prepare_message = PrepareMessage(query=prepared_statement.query_string,
prepare_message = PrepareMessage(query=self.prepared_statement.query_string,

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'm looking at those changes and I don't see what you are trying to achieve.
In PR description you wrote:

Expected behavior

If the driver still has the original PreparedStatement context, it should:

  1. issue PREPARE,
  2. then retry the original EXECUTE, regardless of why the coordinator no longer recognizes the prepared id.

But this is what the driver already does?

  1. Cache the in-flight self.prepared_statement first (when present).

Why does it need to be done first?

In general the existing logic looks sound to me:

  • In first step it determines id of preprared statement, trying both self.prepared_statement and response.info, and also validating protocol assumptions.
  • Then it resolves prepared statement object, also trying both cluster-scoped cache (self.session.cluster._prepared_statements) and self.prepared_statement. It also adds self.prepared_statement to cache in that case.

So, what problem is this PR trying to solve? In what scenario does existing logic fail?

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.

this is one of this scenarios scylladb/scylladb#27657
the driver should retry but it doesn't

@Lorak-mmk Lorak-mmk Mar 4, 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.

Linked issue describes the need for automatic reprepare. Driver already had mechanism for automatic repreparation - your PR doesn't add it, just modifies its logic. Which is why I'm asking what EXACTLY was wrong with the previous logic, how exactly it not working.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ideally we'd have a test that fails before the fix and passes after it. May not be easy to reproduce perhaps.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ideally we'd have a test that fails before the fix and passes after it.

Sure, but for now I'd be happy with a textual reasoning, so that I can understand 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.

@Lorak-mmk , here is the part of the mentioned discussion: scylladb/scylladb#27657 (comment)

If I understand correctly there was an experiment when Scylla returned UNPREPARED = 0x2500 sc to driver and it didnt repreapre the statement. So if driver already has mechanism for automatic repreparation it didnt work during the experiment - scylladb/scylladb#27657 (comment)

@Lorak-mmk Lorak-mmk Mar 4, 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.

Yes, I got that. What I don't understand from this PR description is why exactly it didn't work, and how those changes address that.

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.

Before this PR the logic works roughly like this:

If self.prepared_statement exists, the code forces the query_id to be the local one (self.prepared_statement.query_id).
It then asserts that this id matches the one returned by the server (response.info).
After that it tries to resolve the statement:
It looks it up in the cache using
cluster._prepared_statements[query_id].
If that lookup fails (KeyError), it falls back to the local prepared statement (self.prepared_statement) and stores it in the cache under that query_id.

now this is might be problematic because:

  1. It ignores the server’s returned ID when a local statement exists
    If we already have a local prepared statement, the code overwrites the ID with the local one before doing the lookup.
    That means the ID returned by the server is effectively ignored in this path, even though it might be the correct one to use.

  2. The assertion can break recovery
    The code asserts that the local ID and the server-returned ID must match.
    However, in real situations (for example cache invalidation, server restart or crushes), the server might legitimately return a different ID.
    When that happens, the assertion triggers and kills the flow before the driver has a chance to recover or re-prepare the statement gracefully.

  3. Cache updates may reinforce the wrong mapping
    If the cache lookup fails, the code stores the local prepared statement back into the cache under the current query_id.
    But in this branch the query_id is the local ID, not necessarily the one the server returned.
    Because of this, the logic never explicitly tries the clean path of:
    First resolve using the ID the server returned, and only fall back to the local statement if needed.

In some LWT tests we actually see this behavior, instead of re-sending its just returns does nothing and waits forever

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Yes, I got that. What I don't understand from this PR description is why exactly it didn't work, and how those changes address that.

why exactly it didn't work

This was actually a question for the drivers team :)

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.

This was actually a question for the drivers team :)

sadly almost 2 months ago :(

@mykaul

mykaul commented Mar 4, 2026

Copy link
Copy Markdown

Dear @copilot (1) explain what is wrong in the current implementation and how this code change fixes the issue (2) can you come up with a test that fails before the change and passes after?

Copilot AI commented Mar 4, 2026

Copy link
Copy Markdown

@mykaul I've opened a new pull request, #726, to work on those changes. Once the pull request is ready, I'll request review from you.

@mykaul

mykaul commented Mar 4, 2026

Copy link
Copy Markdown

@mykaul I've opened a new pull request, #726, to work on those changes. Once the pull request is ready, I'll request review from you.

Oh dear. Oh well. Will take a look.

@calebxyz

calebxyz commented Mar 4, 2026

Copy link
Copy Markdown
Author

@mykaul I've opened a new pull request, #726, to work on those changes. Once the pull request is ready, I'll request review from you.

Oh dear. Oh well. Will take a look.

@mykaul was my explanation bad ? :) #725 (comment)

@mykaul

mykaul commented Mar 4, 2026

Copy link
Copy Markdown

@mykaul I've opened a new pull request, #726, to work on those changes. Once the pull request is ready, I'll request review from you.

Oh dear. Oh well. Will take a look.

@mykaul was my explanation bad ? :) #725 (comment)

Honestly - #726 (comment) is not bad as an explanation.

(What I'm still missing is how we can get to a situation of a different ID. That's not very clear to me, but I suspect it's a server-side issue?)

@calebxyz

calebxyz commented Mar 4, 2026

Copy link
Copy Markdown
Author

server-side issue

yes exactly

@mykaul

mykaul commented Mar 9, 2026

Copy link
Copy Markdown

@calebxyz - please resolve review conversation with Copilot, and let's see what else remains here.

@calebxyz

calebxyz commented Mar 9, 2026

Copy link
Copy Markdown
Author

@calebxyz - please resolve review conversation with Copilot, and let's see what else remains here.

done, its all in the code, just had to be resolved in chat.

@calebxyz

Copy link
Copy Markdown
Author

@mykaul how should we progress with this?

@mykaul

mykaul commented Mar 12, 2026

Copy link
Copy Markdown

@dkropachev - please schedule reviewing this or assign it for review after private link - we are seeing this hitting us in Scylla CI. If there's any open issues @calebxyz can fix already, please comment.

Comment thread cassandra/cluster.py Outdated

try:
prepared_statement = self.session.cluster._prepared_statements[query_id]
self.prepared_statement = self.session.cluster._prepared_statements[query_id]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] Keep execution context separate from the statement being reprepared

This replaces execution-wide state with one attempt's missing statement. For an idempotent prepared batch with speculative execution, coordinator 1 can request A and coordinator 2 can request B before PREPARE(A) completes. The future then holds B, so the valid PREPARED(A) response raises an ID-mismatch error and writes A's result metadata into B. I reproduced this callback order: the PR head fails while the base handler does not.

It also affects the accepted unexpected-ID case: EXECUTE(A) with cached returned-ID B prepares B, then retries message A using B's decoding metadata. Preserve the original execution context and associate each PREPARE completion with its corresponding statement while retaining returned-ID-first lookup.

@calebxyz

Copy link
Copy Markdown
Author

@dkropachev - please schedule reviewing this or assign it for review after private link - we are seeing this hitting us in Scylla CI. If there's any open issues @calebxyz can fix already, please comment.

do we still see this problem after i have patched scylladb ?

… for prepared statements.

  ### Problem

  When the server returned UNPREPARED (PreparedQueryNotFound) during an EXECUTE, the driver could fail to recover in some valid cases (cache miss, race, coordinator restart, id mismatch edge cases), even when the in-flight request still had enough PreparedStatement context to recover.

  ### Expected behavior

  If the driver still has the original PreparedStatement context, it should:

  1. issue PREPARE,
  2. then retry the original EXECUTE,
     regardless of why the coordinator no longer recognizes the prepared id.

  ### What this PR changes

  In ResponseFuture PreparedQueryNotFound handling:

  1. Cache the in-flight self.prepared_statement first (when present).
  2. Attempt lookup by the returned UNPREPARED id from cluster._prepared_statements.
  3. Reprepare using the resolved statement.
  4. If returned-id lookup fails:
      - fallback to in-flight context if available,
      - otherwise fail as unknown prepared statement.

  This preserves correctness while improving recovery in real-world coordinator invalidation/race/restart scenarios.

  ### Tests

  Added/updated unit coverage in tests/unit/test_response_future.py:

  - validates reprepare path for PreparedQueryNotFound,
  - validates fallback to in-flight context,
  - validates preference for statement resolved by returned UNPREPARED id when available.

Fixes: scylladb/scylladb#27657
@coderabbitai

coderabbitai Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: QUIET
  • Plan: Essentials
  • Run ID: 1e66a013-2b74-4494-bd0f-0f5a0b6132d0
Commits

Reviewing files that changed from the base of the PR and between 2ef43c8 and d9db800.

Files selected for processing: 2
  • cassandra/cluster.py
  • tests/unit/test_response_future.py

Included review availability: This review used your included allowance. 4 included reviews remain after this review. Your included PR review attempts over the past 7 days set your current allowance at 5 reviews per hour.

Walkthrough

When a server returns UNPREPARED, ResponseFuture looks up the returned query ID and reprepares the matching cached statement. If the ID is not cached, it uses the in-flight statement when available. The PREPARE response is validated against the selected statement, and its result metadata is updated on that statement.

Sequence Diagram(s)

sequenceDiagram
  participant Server
  participant ResponseFuture
  participant Cache as Prepared-statement cache
  participant Host
  Server-->>ResponseFuture: Return UNPREPARED with query ID
  ResponseFuture->>Cache: Look up query ID
  Cache-->>ResponseFuture: Return matching statement or no match
  ResponseFuture->>Server: Send PREPARE for selected statement
  Server-->>ResponseFuture: Return PREPARED response
  ResponseFuture->>ResponseFuture: Validate ID and update selected statement metadata
  ResponseFuture->>Host: Retry original query on original host
Loading

Possibly related issues

  • Issue 1148 — The issue investigates changed prepared-statement IDs; this PR handles an unexpected UNPREPARED ID through lookup or fallback, but the issue also covers separate PREPARE-response behavior.

Suggested reviewers: dkropachev

Priority: Normal

Change: Bug fix

Merge Risk: Minimal · up to d9db8

No actionable merge-blocking issue was established; the change is ready for normal checks.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 31.25% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 16 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description check Passed The description explains the problem, expected behavior, implementation, tests, and linked issue. The checklist is completed; unchecked docstring and documentation items are consistent with the absenc…
Title check Passed The title clearly identifies the main change: fixing UNPREPARED handling in the Python driver execution path.
Linked Issues check Passed Issue #27657 is closed. It provides historical context only. No active directly linked issue supplies coding requirements for this pull request.
Out of Scope Changes check Passed The reported changes are limited to PreparedQueryNotFound recovery in cassandra/cluster.py and focused unit tests in tests/unit/test_response_future.py. These changes match the pull request intent.
Full details: Description check

Explanation

The description explains the problem, expected behavior, implementation, tests, and linked issue. The checklist is completed; unchecked docstring and documentation items are consistent with the absence of new public entities and documentation changes.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@calebxyz

Copy link
Copy Markdown
Author

new version: detached unprepared and execution flow also ID mismatch: after setting the exception it now returns. Before, it went on to write metadata and re-send the query.

@qodo-scylladb

qodo-scylladb Bot commented Oct 26, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Great, no issues found!

Qodo reviewed your code and found no material issues that require review
Tip of the day
💡 Did you know, you can type 'qodo, fix this' on a finding and the fix lands right on your PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗


Powered by Qodo

@calebxyz

Copy link
Copy Markdown
Author

it was implemented long time ago, good that AI remembers what i have done here

@codecov

codecov Bot commented Oct 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.00000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 86.78%. Comparing base (2ef43c8) to head (d9db800).

Files with missing lines Patch % Lines
cassandra/cluster.py 95.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #725      +/-   ##
==========================================
+ Coverage   86.72%   86.78%   +0.05%     
==========================================
  Files          44       44              
  Lines       19029    19031       +2     
  Branches     2968     2969       +1     
==========================================
+ Hits        16503    16516      +13     
+ Misses       1858     1849       -9     
+ Partials      668      666       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@calebxyz

Copy link
Copy Markdown
Author

test libev (3.15t) fails on master as well so it seems like a flaky

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

7 participants