Skip to content

Commit 00b14ef

Browse files
committed
Fix SimpleStatement.is_lwt(): detect LWT from CQL query string
SimpleStatement.is_lwt() always returned False, which meant LWT-aware routing (TokenAwarePolicy Paxos leader routing) and retry policies never activated for: - Raw CQL queries via session.execute(SimpleStatement(...)) - All cqlengine queries (which wrap everything in SimpleStatement) This adds regex-based LWT detection to SimpleStatement that matches: - INSERT ... IF NOT EXISTS - UPDATE/DELETE ... IF EXISTS - UPDATE/DELETE ... IF <column> <op> <value> (conditional) The result is cached after the first call. This is a best-effort heuristic; PreparedStatement continues to get the authoritative is_lwt flag from the server during PREPARE. Also updates the existing BatchStatement LWT test to use the new SimpleStatement.is_lwt() directly instead of a workaround subclass.
1 parent e2a9511 commit 00b14ef

2 files changed

Lines changed: 145 additions & 9 deletions

File tree

cassandra/query.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@
2323
import re
2424
import struct
2525
import time
26+
27+
# Regex to detect LWT (Lightweight Transaction) queries in CQL strings.
28+
# Matches: INSERT ... IF NOT EXISTS, UPDATE/DELETE ... IF EXISTS,
29+
# and conditional updates/deletes (e.g. UPDATE ... IF col = ...).
30+
# Uses word boundaries and case-insensitive matching.
31+
# This is a best-effort heuristic for SimpleStatement; PreparedStatement
32+
# gets the authoritative is_lwt flag from the server.
33+
_LWT_PATTERN = re.compile(
34+
r'\bIF\s+(?:NOT\s+)?EXISTS\b' # IF [NOT] EXISTS
35+
r'|\bIF\s+[a-zA-Z_]', # IF <column_name> (conditional)
36+
re.IGNORECASE
37+
)
2638
import warnings
2739

2840
from cassandra import ConsistencyLevel, OperationTimedOut
@@ -416,6 +428,24 @@ def __str__(self):
416428
(self.query_string, consistency))
417429
__repr__ = __str__
418430

431+
def is_lwt(self):
432+
"""
433+
Detect whether this query is a Lightweight Transaction (LWT) by
434+
inspecting the query string for ``IF [NOT] EXISTS`` or ``IF <condition>``
435+
clauses.
436+
437+
This is a best-effort heuristic. For authoritative LWT detection,
438+
use :class:`.PreparedStatement` which gets the ``is_lwt`` flag from
439+
the server during PREPARE.
440+
441+
The result is cached after the first call.
442+
"""
443+
try:
444+
return self._cached_is_lwt
445+
except AttributeError:
446+
self._cached_is_lwt = bool(_LWT_PATTERN.search(self._query_string))
447+
return self._cached_is_lwt
448+
419449

420450
class PreparedStatement(object):
421451
"""

tests/unit/test_query.py

Lines changed: 115 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -103,15 +103,121 @@ def test_is_lwt_propagates_from_statements(self):
103103
batch_with_bound.add(bound_lwt)
104104
assert batch_with_bound.is_lwt() is True
105105

106-
class LwtSimpleStatement(SimpleStatement):
107-
def __init__(self):
108-
super(LwtSimpleStatement, self).__init__(
109-
"INSERT INTO test.table (id) VALUES (2) IF NOT EXISTS"
110-
)
111-
112-
def is_lwt(self):
113-
return True
106+
# SimpleStatement now detects LWT from query string (no subclass needed)
107+
lwt_simple = SimpleStatement(
108+
"INSERT INTO test.table (id) VALUES (2) IF NOT EXISTS"
109+
)
110+
assert lwt_simple.is_lwt() is True
114111

115112
batch_with_simple = BatchStatement()
116-
batch_with_simple.add(LwtSimpleStatement())
113+
batch_with_simple.add(lwt_simple)
117114
assert batch_with_simple.is_lwt() is True
115+
116+
class SimpleStatementIsLwtTest(unittest.TestCase):
117+
"""Tests for SimpleStatement.is_lwt() CQL-based LWT detection."""
118+
119+
# --- INSERT IF NOT EXISTS ---
120+
121+
def test_insert_if_not_exists(self):
122+
s = SimpleStatement("INSERT INTO ks.t (a) VALUES (1) IF NOT EXISTS")
123+
assert s.is_lwt() is True
124+
125+
def test_insert_if_not_exists_lowercase(self):
126+
s = SimpleStatement("insert into ks.t (a) values (1) if not exists")
127+
assert s.is_lwt() is True
128+
129+
def test_insert_if_not_exists_mixed_case(self):
130+
s = SimpleStatement("INSERT INTO ks.t (a) VALUES (1) If Not Exists")
131+
assert s.is_lwt() is True
132+
133+
# --- UPDATE IF EXISTS ---
134+
135+
def test_update_if_exists(self):
136+
s = SimpleStatement("UPDATE ks.t SET a=1 WHERE k=1 IF EXISTS")
137+
assert s.is_lwt() is True
138+
139+
# --- DELETE IF EXISTS ---
140+
141+
def test_delete_if_exists(self):
142+
s = SimpleStatement("DELETE FROM ks.t WHERE k=1 IF EXISTS")
143+
assert s.is_lwt() is True
144+
145+
# --- Conditional UPDATE (IF <column> = <value>) ---
146+
147+
def test_conditional_update_equals(self):
148+
s = SimpleStatement("UPDATE ks.t SET a=1 WHERE k=1 IF a = 2")
149+
assert s.is_lwt() is True
150+
151+
def test_conditional_update_not_equals(self):
152+
s = SimpleStatement("UPDATE ks.t SET a=1 WHERE k=1 IF a != 2")
153+
assert s.is_lwt() is True
154+
155+
def test_conditional_update_greater_than(self):
156+
s = SimpleStatement("UPDATE ks.t SET a=1 WHERE k=1 IF a > 2")
157+
assert s.is_lwt() is True
158+
159+
def test_conditional_update_multiple_conditions(self):
160+
s = SimpleStatement(
161+
"UPDATE ks.t SET a=1 WHERE k=1 IF a = 2 AND b = 3")
162+
assert s.is_lwt() is True
163+
164+
# --- Conditional DELETE ---
165+
166+
def test_conditional_delete(self):
167+
s = SimpleStatement("DELETE FROM ks.t WHERE k=1 IF a = 2")
168+
assert s.is_lwt() is True
169+
170+
# --- Non-LWT queries (should return False) ---
171+
172+
def test_select_not_lwt(self):
173+
s = SimpleStatement("SELECT * FROM ks.t WHERE k=1")
174+
assert s.is_lwt() is False
175+
176+
def test_insert_without_if(self):
177+
s = SimpleStatement("INSERT INTO ks.t (a) VALUES (1)")
178+
assert s.is_lwt() is False
179+
180+
def test_update_without_if(self):
181+
s = SimpleStatement("UPDATE ks.t SET a=1 WHERE k=1")
182+
assert s.is_lwt() is False
183+
184+
def test_delete_without_if(self):
185+
s = SimpleStatement("DELETE FROM ks.t WHERE k=1")
186+
assert s.is_lwt() is False
187+
188+
def test_create_table_with_if_not_exists(self):
189+
"""DDL IF NOT EXISTS should also match — this is harmless since
190+
DDL queries don't use token-aware routing anyway."""
191+
s = SimpleStatement("CREATE TABLE IF NOT EXISTS ks.t (a int PRIMARY KEY)")
192+
assert s.is_lwt() is True # False positive but harmless
193+
194+
# --- Caching ---
195+
196+
def test_result_is_cached(self):
197+
s = SimpleStatement("INSERT INTO ks.t (a) VALUES (1) IF NOT EXISTS")
198+
assert s.is_lwt() is True
199+
assert s.is_lwt() is True # should use cache
200+
assert s._cached_is_lwt is True
201+
202+
def test_non_lwt_result_is_cached(self):
203+
s = SimpleStatement("SELECT * FROM ks.t")
204+
assert s.is_lwt() is False
205+
assert s._cached_is_lwt is False
206+
207+
# --- Edge cases ---
208+
209+
def test_multiline_query(self):
210+
s = SimpleStatement("""
211+
INSERT INTO ks.t (a, b)
212+
VALUES (1, 2)
213+
IF NOT EXISTS
214+
""")
215+
assert s.is_lwt() is True
216+
217+
def test_extra_whitespace(self):
218+
s = SimpleStatement("UPDATE ks.t SET a=1 WHERE k=1 IF EXISTS")
219+
assert s.is_lwt() is True
220+
221+
def test_tab_separated(self):
222+
s = SimpleStatement("DELETE FROM ks.t WHERE k=1\tIF\tEXISTS")
223+
assert s.is_lwt() is True

0 commit comments

Comments
 (0)