Skip to content

Commit 6f1be93

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 6f1be93

2 files changed

Lines changed: 131 additions & 4 deletions

File tree

cassandra/cqlengine/management.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,21 @@ 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+
try:
274+
table = cluster.metadata.keyspaces[ks_name].tables[raw_cf_name]
275+
except KeyError:
276+
# Table metadata may not yet be available if schema agreement
277+
# timed out or the automatic refresh was skipped after DDL.
278+
# Force a targeted refresh and retry once.
279+
cluster.refresh_table_metadata(ks_name, raw_cf_name)
280+
try:
281+
table = cluster.metadata.keyspaces[ks_name].tables[raw_cf_name]
282+
except KeyError:
283+
msg = format_log_context(
284+
"Table metadata for '{0}'.'{1}' is not available after refresh. "
285+
"Check schema agreement and cluster health.",
286+
keyspace=ks_name, connection=connection)
287+
raise CQLEngineException(msg.format(ks_name, raw_cf_name))
274288

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

@@ -431,9 +445,20 @@ def _get_table_metadata(model, connection=None):
431445
# returns the table as provided by the native driver for a given model
432446
cluster = get_cluster(connection)
433447
ks = model._get_keyspace()
434-
table = model._raw_column_family_name()
435-
table = cluster.metadata.keyspaces[ks].tables[table]
436-
return table
448+
raw_cf_name = model._raw_column_family_name()
449+
try:
450+
return cluster.metadata.keyspaces[ks].tables[raw_cf_name]
451+
except KeyError:
452+
# Metadata may be stale; force a targeted refresh and retry once.
453+
cluster.refresh_table_metadata(ks, raw_cf_name)
454+
try:
455+
return cluster.metadata.keyspaces[ks].tables[raw_cf_name]
456+
except KeyError:
457+
msg = format_log_context(
458+
"Table metadata for '{0}'.'{1}' is not available after refresh. "
459+
"Check schema agreement and cluster health.",
460+
keyspace=ks, connection=connection)
461+
raise CQLEngineException(msg.format(ks, raw_cf_name))
437462

438463

439464
def _options_map_from_strings(option_strings):
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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 _sync_table and _get_table_metadata gracefully
19+
handle missing table metadata by forcing a targeted refresh and retrying.
20+
"""
21+
22+
import unittest
23+
from unittest.mock import patch, MagicMock, PropertyMock
24+
25+
from cassandra.cqlengine import CQLEngineException
26+
from cassandra.cqlengine.management import _get_table_metadata
27+
28+
29+
class MockTableMeta:
30+
"""Minimal stand-in for TableMetadata."""
31+
32+
def __init__(self):
33+
self.columns = {}
34+
self.options = {}
35+
self.partition_key = []
36+
self.clustering_key = []
37+
38+
39+
class TestGetTableMetadataRetry(unittest.TestCase):
40+
"""Tests for _get_table_metadata retry on KeyError."""
41+
42+
def _make_model(self, ks="test_ks", table="test_table"):
43+
model = MagicMock()
44+
model._get_keyspace.return_value = ks
45+
model._raw_column_family_name.return_value = table
46+
return model
47+
48+
@patch("cassandra.cqlengine.management.get_cluster")
49+
def test_returns_table_when_present(self, mock_get_cluster):
50+
"""Table metadata is found on first lookup -- no refresh needed."""
51+
table_meta = MockTableMeta()
52+
cluster = MagicMock()
53+
cluster.metadata.keyspaces = {
54+
"test_ks": MagicMock(tables={"test_table": table_meta})
55+
}
56+
mock_get_cluster.return_value = cluster
57+
model = self._make_model()
58+
59+
result = _get_table_metadata(model)
60+
self.assertIs(result, table_meta)
61+
cluster.refresh_table_metadata.assert_not_called()
62+
63+
@patch("cassandra.cqlengine.management.get_cluster")
64+
def test_retries_after_refresh_on_missing_table(self, mock_get_cluster):
65+
"""Table missing initially, but available after refresh."""
66+
table_meta = MockTableMeta()
67+
cluster = MagicMock()
68+
69+
# First lookup: table not in tables dict. After refresh: table is there.
70+
tables_first = {}
71+
tables_after = {"test_table": table_meta}
72+
ks_meta = MagicMock()
73+
type(ks_meta).tables = PropertyMock(side_effect=[tables_first, tables_after])
74+
cluster.metadata.keyspaces = {"test_ks": ks_meta}
75+
mock_get_cluster.return_value = cluster
76+
77+
model = self._make_model()
78+
result = _get_table_metadata(model)
79+
80+
self.assertIs(result, table_meta)
81+
cluster.refresh_table_metadata.assert_called_once_with("test_ks", "test_table")
82+
83+
@patch("cassandra.cqlengine.management.get_cluster")
84+
def test_raises_after_failed_refresh(self, mock_get_cluster):
85+
"""Table missing even after refresh -- raises CQLEngineException."""
86+
cluster = MagicMock()
87+
ks_meta = MagicMock()
88+
type(ks_meta).tables = PropertyMock(return_value={})
89+
cluster.metadata.keyspaces = {"test_ks": ks_meta}
90+
mock_get_cluster.return_value = cluster
91+
92+
model = self._make_model()
93+
94+
with self.assertRaises(CQLEngineException) as ctx:
95+
_get_table_metadata(model)
96+
97+
self.assertIn("not available after refresh", str(ctx.exception))
98+
cluster.refresh_table_metadata.assert_called_once_with("test_ks", "test_table")
99+
100+
101+
if __name__ == "__main__":
102+
unittest.main()

0 commit comments

Comments
 (0)