Skip to content

Commit d5c3f18

Browse files
committed
(fix) cqlengine: handle missing table metadata after schema change in sync_table
After CREATE TABLE or ALTER TABLE, the local metadata cache may not yet contain the new table if schema agreement timed out or the automatic metadata refresh was skipped. _sync_table() and _get_table_metadata() unconditionally accessed cluster.metadata.keyspaces[ks].tables[table], which raised KeyError in this case. Wrap both lookups in try/except KeyError. On miss, force a targeted cluster.refresh_table_metadata() call and retry once. If the table is still not present after the forced refresh, raise a descriptive CQLEngineException instead of a bare KeyError. This follows the same defensive pattern already used in _sync_type(), which calls cluster.refresh_user_type_metadata() after CREATE TYPE. Add unit tests for _get_table_metadata verifying: immediate hit (no refresh), successful retry after refresh, and failure after refresh.
1 parent 9c53d78 commit d5c3f18

2 files changed

Lines changed: 180 additions & 4 deletions

File tree

cassandra/cqlengine/management.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ def _sync_table(model, connection=None):
270270

271271
_update_options(model, connection=connection)
272272

273-
table = cluster.metadata.keyspaces[ks_name].tables[raw_cf_name]
273+
table = _get_table_metadata(model, connection)
274274

275275
indexes = [c for n, c in model._columns.items() if c.index]
276276

@@ -431,9 +431,20 @@ def _get_table_metadata(model, connection=None):
431431
# returns the table as provided by the native driver for a given model
432432
cluster = get_cluster(connection)
433433
ks = model._get_keyspace()
434-
table = model._raw_column_family_name()
435-
table = cluster.metadata.keyspaces[ks].tables[table]
436-
return table
434+
raw_cf_name = model._raw_column_family_name()
435+
try:
436+
return cluster.metadata.keyspaces[ks].tables[raw_cf_name]
437+
except KeyError:
438+
# Metadata may be stale; force a targeted refresh and retry once.
439+
cluster.refresh_table_metadata(ks, raw_cf_name)
440+
try:
441+
return cluster.metadata.keyspaces[ks].tables[raw_cf_name]
442+
except KeyError:
443+
msg = format_log_context(
444+
"Table metadata for '{0}'.'{1}' is not available after refresh. "
445+
"Check schema agreement and cluster health.",
446+
keyspace=ks, connection=connection)
447+
raise CQLEngineException(msg.format(ks, raw_cf_name))
437448

438449

439450
def _options_map_from_strings(option_strings):
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# Copyright DataStax, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
Unit tests for cassandra.cqlengine.management module.
17+
18+
Focuses on verifying that _get_table_metadata gracefully handles missing
19+
table metadata by forcing a targeted refresh and retrying, and that
20+
_sync_table delegates to _get_table_metadata for post-DDL metadata lookup.
21+
"""
22+
23+
import unittest
24+
from unittest.mock import patch, MagicMock, PropertyMock
25+
26+
from cassandra.cqlengine import CQLEngineException
27+
from cassandra.cqlengine.management import _get_table_metadata, _sync_table
28+
29+
30+
class MockTableMeta:
31+
"""Minimal stand-in for TableMetadata."""
32+
33+
def __init__(self):
34+
self.columns = {}
35+
self.options = {}
36+
self.partition_key = []
37+
self.clustering_key = []
38+
39+
40+
class TestGetTableMetadataRetry(unittest.TestCase):
41+
"""Tests for _get_table_metadata retry on KeyError."""
42+
43+
def _make_model(self, ks="test_ks", table="test_table"):
44+
model = MagicMock()
45+
model._get_keyspace.return_value = ks
46+
model._raw_column_family_name.return_value = table
47+
return model
48+
49+
@patch("cassandra.cqlengine.management.get_cluster")
50+
def test_returns_table_when_present(self, mock_get_cluster):
51+
"""Table metadata is found on first lookup -- no refresh needed."""
52+
table_meta = MockTableMeta()
53+
cluster = MagicMock()
54+
cluster.metadata.keyspaces = {
55+
"test_ks": MagicMock(tables={"test_table": table_meta})
56+
}
57+
mock_get_cluster.return_value = cluster
58+
model = self._make_model()
59+
60+
result = _get_table_metadata(model)
61+
self.assertIs(result, table_meta)
62+
cluster.refresh_table_metadata.assert_not_called()
63+
64+
@patch("cassandra.cqlengine.management.get_cluster")
65+
def test_retries_after_refresh_on_missing_table(self, mock_get_cluster):
66+
"""Table missing initially, but available after refresh."""
67+
table_meta = MockTableMeta()
68+
cluster = MagicMock()
69+
70+
# First lookup: table not in tables dict. After refresh: table is there.
71+
tables_first = {}
72+
tables_after = {"test_table": table_meta}
73+
ks_meta = MagicMock()
74+
type(ks_meta).tables = PropertyMock(side_effect=[tables_first, tables_after])
75+
cluster.metadata.keyspaces = {"test_ks": ks_meta}
76+
mock_get_cluster.return_value = cluster
77+
78+
model = self._make_model()
79+
result = _get_table_metadata(model)
80+
81+
self.assertIs(result, table_meta)
82+
cluster.refresh_table_metadata.assert_called_once_with("test_ks", "test_table")
83+
84+
@patch("cassandra.cqlengine.management.get_cluster")
85+
def test_raises_after_failed_refresh(self, mock_get_cluster):
86+
"""Table missing even after refresh -- raises CQLEngineException."""
87+
cluster = MagicMock()
88+
ks_meta = MagicMock()
89+
type(ks_meta).tables = PropertyMock(return_value={})
90+
cluster.metadata.keyspaces = {"test_ks": ks_meta}
91+
mock_get_cluster.return_value = cluster
92+
93+
model = self._make_model()
94+
95+
with self.assertRaises(CQLEngineException) as ctx:
96+
_get_table_metadata(model)
97+
98+
self.assertIn("not available after refresh", str(ctx.exception))
99+
cluster.refresh_table_metadata.assert_called_once_with("test_ks", "test_table")
100+
101+
102+
class TestSyncTableMetadataLookup(unittest.TestCase):
103+
"""Tests that _sync_table delegates metadata lookup to _get_table_metadata."""
104+
105+
def _make_model(self, ks="test_ks", table="test_table"):
106+
"""Create a mock model that passes _sync_table's precondition checks."""
107+
model = MagicMock()
108+
model.__abstract__ = False
109+
model.column_family_name.return_value = '"test_ks"."test_table"'
110+
model._raw_column_family_name.return_value = table
111+
model._get_keyspace.return_value = ks
112+
model._get_connection.return_value = None
113+
model._columns = {}
114+
return model
115+
116+
@patch("cassandra.cqlengine.management._get_table_metadata")
117+
@patch("cassandra.cqlengine.management._get_create_table", return_value="CREATE TABLE test")
118+
@patch("cassandra.cqlengine.management.execute")
119+
@patch("cassandra.cqlengine.management.get_cluster")
120+
@patch("cassandra.cqlengine.management._allow_schema_modification", return_value=True)
121+
@patch("cassandra.cqlengine.management.issubclass", return_value=True)
122+
def test_calls_get_table_metadata_after_create(
123+
self, mock_issubclass, mock_allow, mock_get_cluster,
124+
mock_execute, mock_create, mock_get_meta
125+
):
126+
"""After creating a new table, _sync_table calls _get_table_metadata."""
127+
table_meta = MockTableMeta()
128+
mock_get_meta.return_value = table_meta
129+
130+
cluster = MagicMock()
131+
ks_meta = MagicMock()
132+
ks_meta.tables = {} # table not in tables -> triggers CREATE TABLE
133+
cluster.metadata.keyspaces = {"test_ks": ks_meta}
134+
mock_get_cluster.return_value = cluster
135+
136+
model = self._make_model()
137+
_sync_table(model)
138+
139+
mock_get_meta.assert_called_once_with(model, None)
140+
141+
@patch("cassandra.cqlengine.management._get_table_metadata")
142+
@patch("cassandra.cqlengine.management._get_create_table", return_value="CREATE TABLE test")
143+
@patch("cassandra.cqlengine.management.execute")
144+
@patch("cassandra.cqlengine.management.get_cluster")
145+
@patch("cassandra.cqlengine.management._allow_schema_modification", return_value=True)
146+
@patch("cassandra.cqlengine.management.issubclass", return_value=True)
147+
def test_propagates_exception_from_get_table_metadata(
148+
self, mock_issubclass, mock_allow, mock_get_cluster,
149+
mock_execute, mock_create, mock_get_meta
150+
):
151+
"""CQLEngineException from _get_table_metadata propagates out of _sync_table."""
152+
mock_get_meta.side_effect = CQLEngineException("Table metadata not available")
153+
154+
cluster = MagicMock()
155+
ks_meta = MagicMock()
156+
ks_meta.tables = {}
157+
cluster.metadata.keyspaces = {"test_ks": ks_meta}
158+
mock_get_cluster.return_value = cluster
159+
160+
model = self._make_model()
161+
162+
with self.assertRaises(CQLEngineException) as ctx:
163+
_sync_table(model)
164+
165+
self.assertIn("not available", str(ctx.exception))

0 commit comments

Comments
 (0)