Skip to content

Commit f68f12f

Browse files
mday-ioclaude
andcommitted
fix(clickhouse): suppress ON CLUSTER for object DDL in a Replicated database
Inside a `Replicated` database Keeper already propagates object DDL, so adding `ON CLUSTER` asks for a second, redundant fan-out and ClickHouse rejects the statement outright: Code: 80. DB::Exception: It's not initial query. ON CLUSTER is not allowed for Replicated database. (INCORRECT_QUERY) The ClickHouse adapter had no way to avoid this: `_on_cluster_sql()` took no argument and keyed only on `engine_run_mode.is_cluster`. The practical effect is total -- with a cluster configured, no table or view can be created in a `Replicated` database at all. Reproduced end to end against a 1x3 cluster on 26.6.3.62: `create_table` into such a database fails with the error above before this change and succeeds after it, propagating to all three replicas under one shared UUID. This cannot be a connection-level flag. One connection can hold both an Atomic and a `Replicated` database, and objects in the Atomic one still need `ON CLUSTER`, so the decision is made per target database by `_should_use_on_cluster()` and threaded through all 14 emission sites. The policy fails open throughout. An unknown target, an unresolvable database, or any introspection failure keeps the previous behaviour, and a single cached probe short-circuits the whole path when the server hosts no `Replicated` database at all -- so this is a no-op, and costs no extra round-trips, for any deployment that does not have one. Two carve-outs are deliberate. `CREATE DATABASE` is how a `Replicated` database comes into existence, so there is no engine to consult, and a database's own DDL log cannot propagate its own removal; both stay cluster-wide unconditionally. `alter_table` decides per expression rather than per call, because one call can carry alters against both kinds of database, and `RENAME` / `EXCHANGE` across a Replicated and a non-Replicated database are refused rather than rendered silently half-correct. `_build_table_properties_exp` and `_build_view_properties_exp` receive no reference to the object being created, so the base class now passes the target table down. The parameter is additive and every override across the nine adapter files already accepts `**kwargs`, so no other adapter changes; Athena already declares and receives the same `table` parameter via its own override. This is a correctness fix independent of managed models: any SQLMesh user running against a `Replicated` database is affected today for ordinary tables and views. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c99553f commit f68f12f

3 files changed

Lines changed: 453 additions & 17 deletions

File tree

sqlmesh/core/engine_adapter/base.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,12 +1043,15 @@ def _build_create_table_exp(
10431043
) -> exp.Create:
10441044
exists = False if replace else exists
10451045
catalog_name = None
1046+
target_table: t.Optional[exp.Table] = None
10461047
if not isinstance(table_name_or_schema, exp.Schema):
10471048
table_name_or_schema = exp.to_table(table_name_or_schema)
10481049
catalog_name = table_name_or_schema.catalog
1050+
target_table = table_name_or_schema
10491051
else:
10501052
if isinstance(table_name_or_schema.this, exp.Table):
10511053
catalog_name = table_name_or_schema.this.catalog
1054+
target_table = table_name_or_schema.this
10521055

10531056
properties = (
10541057
self._build_table_properties_exp(
@@ -1057,6 +1060,9 @@ def _build_create_table_exp(
10571060
target_columns_to_types=target_columns_to_types,
10581061
table_description=table_description,
10591062
table_kind=table_kind,
1063+
# Passed so an adapter can vary properties by target object, not just by
1064+
# connection. Additive: every override accepts **kwargs.
1065+
table=target_table,
10601066
)
10611067
if kwargs or table_description
10621068
else None
@@ -1352,6 +1358,8 @@ def create_view(
13521358
else None
13531359
),
13541360
physical_cluster=create_kwargs.pop("physical_cluster", None),
1361+
# See the note on the table-properties call site above.
1362+
table=exp.to_table(view_name),
13551363
)
13561364
if create_view_properties:
13571365
for view_property in create_view_properties.expressions:

sqlmesh/core/engine_adapter/clickhouse.py

Lines changed: 209 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import typing as t
44
import logging
55
import re
6+
from functools import cached_property
67
from sqlglot import exp, maybe_parse
78
from sqlmesh.core.dialect import to_schema
89
from sqlmesh.core.engine_adapter.mixins import LogicalMergeMixin
@@ -30,6 +31,13 @@
3031

3132
logger = logging.getLogger(__name__)
3233

34+
# `system.databases.engine` value for a Keeper-coordinated database. Object DDL inside one
35+
# must not carry `ON CLUSTER`: the database's own DDL log already propagates it to every
36+
# replica, so ClickHouse rejects the redundant second fan-out outright with code 80,
37+
# `It's not initial query. ON CLUSTER is not allowed for Replicated database.`
38+
# Matched as a prefix so engine variants are covered without another release.
39+
REPLICATED_DATABASE_ENGINE_PREFIX = "Replicated"
40+
3341

3442
class ClickhouseEngineAdapter(EngineAdapterWithIndexSupport, LogicalMergeMixin):
3543
DIALECT = "clickhouse"
@@ -192,9 +200,15 @@ def create_schema(
192200
from sqlmesh.utils.errors import SQLMeshError
193201

194202
properties_copy = properties.copy()
203+
# Always cluster-wide, deliberately. This is the statement that creates the
204+
# database, including a `Replicated` one, so there is no database engine to
205+
# consult yet and nothing for Keeper to propagate through.
195206
if self.engine_run_mode.is_cluster:
196207
properties_copy.append(exp.OnCluster(this=exp.to_identifier(self.cluster)))
197208

209+
# A database that previously resolved as absent may now exist, with an engine.
210+
self._clear_database_engine_cache()
211+
198212
# ClickHouse does not support catalogs. When a virtual catalog has been injected
199213
# (self._default_catalog is set), strip it from the schema name. This mirrors the
200214
# SINGLE_CATALOG_ONLY branch in the set_catalog decorator, which does not apply here
@@ -501,7 +515,8 @@ def _create_table_like(
501515
) -> None:
502516
"""Create table with identical structure as source table"""
503517
self.execute(
504-
f"CREATE TABLE {target_table_name}{self._on_cluster_sql()} AS {source_table_name}"
518+
f"CREATE TABLE {target_table_name}{self._on_cluster_sql(target_table_name)}"
519+
f" AS {source_table_name}"
505520
)
506521

507522
def _get_partition_ids(
@@ -661,10 +676,14 @@ def _exchange_tables(
661676
old_table_sql = exp.to_table(old_table_name).sql(dialect=self.dialect, identify=True)
662677
new_table_sql = exp.to_table(new_table_name).sql(dialect=self.dialect, identify=True)
663678

679+
on_cluster_sql = (
680+
self._on_cluster_sql(old_table_name)
681+
if self._assert_same_on_cluster_scope("EXCHANGE TABLES", old_table_name, new_table_name)
682+
else ""
683+
)
684+
664685
try:
665-
self.execute(
666-
f"EXCHANGE TABLES {old_table_sql} AND {new_table_sql}{self._on_cluster_sql()}"
667-
)
686+
self.execute(f"EXCHANGE TABLES {old_table_sql} AND {new_table_sql}{on_cluster_sql}")
668687
except DatabaseError as e:
669688
if "NOT_IMPLEMENTED" in str(e):
670689
# If someone is using an old Clickhouse version, an OS that doesn't support atomic exchanges,
@@ -686,11 +705,18 @@ def _rename_table(
686705
old_table_sql = exp.to_table(old_table_name).sql(dialect=self.dialect, identify=True)
687706
new_table_sql = exp.to_table(new_table_name).sql(dialect=self.dialect, identify=True)
688707

689-
self.execute(f"RENAME TABLE {old_table_sql} TO {new_table_sql}{self._on_cluster_sql()}")
708+
on_cluster_sql = (
709+
self._on_cluster_sql(old_table_name)
710+
if self._assert_same_on_cluster_scope("RENAME TABLE", old_table_name, new_table_name)
711+
else ""
712+
)
713+
714+
self.execute(f"RENAME TABLE {old_table_sql} TO {new_table_sql}{on_cluster_sql}")
690715

691716
def delete_from(self, table_name: TableName, where: t.Union[str, exp.Expr]) -> None:
692-
delete_expr = exp.delete(self._strip_virtual_catalog(table_name), where)
693-
if self.engine_run_mode.is_cluster:
717+
target_table = self._strip_virtual_catalog(table_name)
718+
delete_expr = exp.delete(target_table, where)
719+
if self._should_use_on_cluster(target_table):
694720
delete_expr.set("cluster", exp.OnCluster(this=exp.to_identifier(self.cluster)))
695721
self.execute(delete_expr)
696722

@@ -708,7 +734,12 @@ def alter_table(
708734
if self._default_catalog and isinstance(alter_expression.this, exp.Table):
709735
if alter_expression.this.catalog == self._default_catalog:
710736
alter_expression.this.set("catalog", None)
711-
if self.engine_run_mode.is_cluster:
737+
# Decided per expression, not hoisted: one call can legitimately carry
738+
# alters against both a Replicated and a non-Replicated database.
739+
altered_table = (
740+
alter_expression.this if isinstance(alter_expression.this, exp.Table) else None
741+
)
742+
if self._should_use_on_cluster(altered_table):
712743
alter_expression.set(
713744
"cluster", exp.OnCluster(this=exp.to_identifier(self.cluster))
714745
)
@@ -732,14 +763,24 @@ def _drop_object(
732763
kind: What kind of object to drop. Defaults to TABLE
733764
**drop_args: Any extra arguments to set on the Drop expression
734765
"""
766+
# Dropping the database itself is always cluster-wide: the Keeper-backed DDL log
767+
# being dropped cannot propagate its own removal, and `name` here is a database
768+
# rather than an object inside one.
769+
is_database = kind.upper() in ("SCHEMA", "DATABASE")
770+
use_on_cluster = (
771+
self.engine_run_mode.is_cluster if is_database else self._should_use_on_cluster(name)
772+
)
773+
774+
if is_database:
775+
# Cheap to rebuild and easy to get wrong for one entry; drop the lot.
776+
self._clear_database_engine_cache()
777+
735778
super()._drop_object(
736779
name=name,
737780
exists=exists,
738781
kind=kind,
739782
cascade=cascade,
740-
cluster=exp.OnCluster(this=exp.to_identifier(self.cluster))
741-
if self.engine_run_mode.is_cluster
742-
else None,
783+
cluster=exp.OnCluster(this=exp.to_identifier(self.cluster)) if use_on_cluster else None,
743784
**drop_args,
744785
)
745786

@@ -841,6 +882,7 @@ def _build_table_properties_exp(
841882
table_description: t.Optional[str] = None,
842883
table_kind: t.Optional[str] = None,
843884
empty_ctas: bool = False,
885+
table: t.Optional[exp.Table] = None,
844886
**kwargs: t.Any,
845887
) -> t.Optional[exp.Properties]:
846888
properties: t.List[exp.Expr] = []
@@ -919,7 +961,7 @@ def _build_table_properties_exp(
919961
):
920962
properties.append(partitioned_by_prop)
921963

922-
if self.engine_run_mode.is_cluster:
964+
if self._should_use_on_cluster(table):
923965
properties.append(exp.OnCluster(this=exp.to_identifier(self.cluster)))
924966

925967
if empty_ctas:
@@ -944,14 +986,15 @@ def _build_view_properties_exp(
944986
self,
945987
view_properties: t.Optional[t.Dict[str, exp.Expr]] = None,
946988
table_description: t.Optional[str] = None,
989+
table: t.Optional[exp.Table] = None,
947990
**kwargs: t.Any,
948991
) -> t.Optional[exp.Properties]:
949992
"""Creates a SQLGlot table properties expression for view"""
950993
properties: t.List[exp.Expr] = []
951994

952995
view_properties_copy = view_properties.copy() if view_properties else {}
953996

954-
if self.engine_run_mode.is_cluster:
997+
if self._should_use_on_cluster(table):
955998
properties.append(exp.OnCluster(this=exp.to_identifier(self.cluster)))
956999

9571000
if view_properties_copy:
@@ -976,7 +1019,7 @@ def _build_create_comment_table_exp(
9761019
truncated_comment = self._truncate_table_comment(table_comment)
9771020
comment_sql = exp.Literal.string(truncated_comment).sql(dialect=self.dialect)
9781021

979-
return f"ALTER TABLE {table_sql}{self._on_cluster_sql()} MODIFY COMMENT {comment_sql}"
1022+
return f"ALTER TABLE {table_sql}{self._on_cluster_sql(table)} MODIFY COMMENT {comment_sql}"
9801023

9811024
def _build_create_comment_column_exp(
9821025
self,
@@ -992,10 +1035,159 @@ def _build_create_comment_column_exp(
9921035
truncated_comment = self._truncate_table_comment(column_comment)
9931036
comment_sql = exp.Literal.string(truncated_comment).sql(dialect=self.dialect)
9941037

995-
return f"ALTER TABLE {table_sql}{self._on_cluster_sql()} COMMENT COLUMN {column_sql} {comment_sql}"
1038+
return (
1039+
f"ALTER TABLE {table_sql}{self._on_cluster_sql(table)}"
1040+
f" COMMENT COLUMN {column_sql} {comment_sql}"
1041+
)
9961042

997-
def _on_cluster_sql(self) -> str:
998-
if self.engine_run_mode.is_cluster:
1043+
@cached_property
1044+
def _database_engine_cache(self) -> t.Dict[str, t.Optional[str]]:
1045+
return {}
1046+
1047+
@cached_property
1048+
def _has_replicated_database(self) -> bool:
1049+
"""Whether this server hosts any Keeper-coordinated database at all.
1050+
1051+
One probe per connection, and the reason this change costs nothing on a
1052+
deployment that has no `Replicated` database: when the answer is no, every
1053+
`ON CLUSTER` decision short-circuits without resolving a target or querying
1054+
`system.databases` again.
1055+
"""
1056+
try:
1057+
row = self.fetchone(
1058+
exp.select(exp.func("count"))
1059+
.from_("system.databases")
1060+
.where(
1061+
exp.column("engine").like(
1062+
exp.Literal.string(f"{REPLICATED_DATABASE_ENGINE_PREFIX}%")
1063+
)
1064+
)
1065+
)
1066+
except Exception:
1067+
return False
1068+
return bool(row and row[0])
1069+
1070+
@cached_property
1071+
def _connection_database(self) -> t.Optional[str]:
1072+
"""The database an unqualified object resolves to on this connection."""
1073+
try:
1074+
row = self.fetchone("SELECT currentDatabase()")
1075+
except Exception:
1076+
return None
1077+
return str(row[0]) if row and row[0] else None
1078+
1079+
def _clear_database_engine_cache(self, database: t.Optional[str] = None) -> None:
1080+
if database is None:
1081+
self._database_engine_cache.clear()
1082+
else:
1083+
self._database_engine_cache.pop(database, None)
1084+
# Creating or dropping a database can also change whether any Replicated one
1085+
# exists, which is what the short-circuit above depends on.
1086+
self.__dict__.pop("_has_replicated_database", None)
1087+
1088+
def _database_engine(self, database: str) -> t.Optional[str]:
1089+
"""The engine of a ClickHouse database, or None when it cannot be resolved.
1090+
1091+
Deliberately connection-local rather than replica-wide: a `Replicated` database's
1092+
engine is uniform by construction, and `clusterAllReplicas` fails outright when any
1093+
host is down, which would turn an unrelated outage into a DDL failure.
1094+
"""
1095+
cache = self._database_engine_cache
1096+
if database in cache:
1097+
return cache[database]
1098+
1099+
engine: t.Optional[str] = None
1100+
try:
1101+
row = self.fetchone(
1102+
exp.select("engine")
1103+
.from_("system.databases")
1104+
.where(exp.column("name").eq(exp.Literal.string(database)))
1105+
)
1106+
if row and row[0]:
1107+
engine = str(row[0])
1108+
except Exception:
1109+
# Unresolvable for any reason - absent, permission-denied, introspection
1110+
# failure. Cache the miss but let the caller fall back to emitting.
1111+
engine = None
1112+
1113+
cache[database] = engine
1114+
return engine
1115+
1116+
def _on_cluster_target_database(self, target: t.Optional[TableName]) -> t.Optional[str]:
1117+
"""The database an object DDL statement targets, or None when unknown."""
1118+
if target is None:
1119+
return None
1120+
1121+
table = exp.to_table(target, dialect=self.dialect) if isinstance(target, str) else target
1122+
if not isinstance(table, exp.Table):
1123+
return None
1124+
1125+
table = self._strip_virtual_catalog(table)
1126+
database = table.db
1127+
# An unqualified name resolves to the connection's database, not to "unknown".
1128+
return database or self._connection_database
1129+
1130+
def _should_use_on_cluster(self, target: t.Optional[TableName] = None) -> bool:
1131+
"""Whether object DDL for `target` should carry `ON CLUSTER`.
1132+
1133+
Inside a `Replicated` database Keeper already propagates object DDL, so adding
1134+
`ON CLUSTER` asks for a second, redundant fan-out and ClickHouse refuses the
1135+
statement with code 80 `INCORRECT_QUERY`. Without this, no object can be created
1136+
in such a database at all while the adapter is in cluster mode.
1137+
Suppression cannot be a connection-level flag: one connection can hold both an
1138+
Atomic and a `Replicated` database, and objects in the Atomic one still need
1139+
`ON CLUSTER`.
1140+
1141+
Fails open. An unknown target, an unresolvable database, or any introspection
1142+
failure keeps today's behaviour, so this is a no-op for every deployment that has
1143+
no `Replicated` database.
1144+
"""
1145+
if not self.engine_run_mode.is_cluster:
1146+
return False
1147+
1148+
if not self._has_replicated_database:
1149+
return True
1150+
1151+
database = self._on_cluster_target_database(target)
1152+
if not database:
1153+
return True
1154+
1155+
engine = self._database_engine(database)
1156+
if engine is None:
1157+
return True
1158+
1159+
return not engine.startswith(REPLICATED_DATABASE_ENGINE_PREFIX)
1160+
1161+
def _on_cluster_sql(self, target: t.Optional[TableName] = None) -> str:
1162+
"""Render the `ON CLUSTER` clause for object DDL against `target`.
1163+
1164+
Omitting `target` means "target unknown" and preserves the pre-existing
1165+
behaviour of always emitting in cluster mode.
1166+
"""
1167+
if self._should_use_on_cluster(target):
9991168
cluster_name = exp.to_identifier(self.cluster, quoted=True).sql(dialect=self.dialect) # type: ignore
10001169
return f" ON CLUSTER {cluster_name} "
10011170
return ""
1171+
1172+
def _assert_same_on_cluster_scope(
1173+
self, operation: str, first: TableName, second: TableName
1174+
) -> bool:
1175+
"""Resolve one `ON CLUSTER` decision for a two-table statement.
1176+
1177+
`RENAME` and `EXCHANGE` can span databases, and a single statement cannot be both
1178+
cluster-wide and Keeper-propagated. Refuse rather than pick one and be silently
1179+
half-correct on the other.
1180+
"""
1181+
from sqlmesh.utils.errors import SQLMeshError
1182+
1183+
first_scope = self._should_use_on_cluster(first)
1184+
second_scope = self._should_use_on_cluster(second)
1185+
if first_scope != second_scope:
1186+
first_sql = exp.to_table(first, dialect=self.dialect).sql(dialect=self.dialect)
1187+
second_sql = exp.to_table(second, dialect=self.dialect).sql(dialect=self.dialect)
1188+
raise SQLMeshError(
1189+
f"Cannot {operation} between a Replicated database and a non-Replicated one: "
1190+
f"{first_sql} and {second_sql} disagree on whether object DDL carries "
1191+
"ON CLUSTER. Move both objects into databases with the same engine."
1192+
)
1193+
return first_scope

0 commit comments

Comments
 (0)