33import typing as t
44import logging
55import re
6+ from functools import cached_property
67from sqlglot import exp , maybe_parse
78from sqlmesh .core .dialect import to_schema
89from sqlmesh .core .engine_adapter .mixins import LogicalMergeMixin
3031
3132logger = 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
3442class 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