Skip to content

ClickhouseEngineAdapter._exchange_tables silently swallows non-NOT_IMPLEMENTED swap failures → FULL model reports success over stale data #6087

Description

@davidstravito

Version: 0.236.1 (also present in 0.230.1, 0.236.2 and current main)
Engine: ClickHouse

What happens

ClickhouseEngineAdapter._exchange_tables (sqlmesh/core/engine_adapter/clickhouse.py)
wraps EXCHANGE TABLES in except DatabaseError but only acts on NOT_IMPLEMENTED
(fall back to a non-atomic rename). There is no else: raise, so any other
DatabaseError from the exchange is caught and discarded:

try:
    self.execute(
        f"EXCHANGE TABLES {old_table_sql} AND {new_table_sql}{self._on_cluster_sql()}"
    )
except DatabaseError as e:
    if "NOT_IMPLEMENTED" in str(e):
        throwaway_table_name = self._get_temp_table(old_table_name)
        self._rename_table(old_table_name, throwaway_table_name)
        self._rename_table(new_table_name, old_table_name)
        self.drop_table(throwaway_table_name)
    # <-- no else: the exception is swallowed

The caller _insert_overwrite_by_condition then drops the freshly-computed temp table in
its finally, so the stale target table stays live and the evaluation returns
success.

Why it matters

ClickHouse sets SUPPORTS_REPLACE_TABLE = False, so a steady-state FULL model's
replace_query routes through _insert_overwrite_by_condition_exchange_tables. A
transient/permission/keeper error during the swap therefore leaves the old data in place
while SQLMesh records the interval as built. Audits do not catch it: the scheduler
evaluates, then audits against the same (now stale) table, then records the interval — so
an audit validates the stale data too. The failure is completely silent.

Expected

A swap failure that is not NOT_IMPLEMENTED should propagate, failing the evaluation
(and, in a DAG, skipping dependents) rather than reporting success over stale data.

Fix

Re-raise anything that is not NOT_IMPLEMENTED:

     except DatabaseError as e:
-        if "NOT_IMPLEMENTED" in str(e):
-            # ... non-atomic rename fallback ...
-            throwaway_table_name = self._get_temp_table(old_table_name)
-            self._rename_table(old_table_name, throwaway_table_name)
-            self._rename_table(new_table_name, old_table_name)
-            self.drop_table(throwaway_table_name)
+        if "NOT_IMPLEMENTED" not in str(e):
+            raise
+        # ... non-atomic rename fallback (old ClickHouse / non-Atomic engine) ...
+        throwaway_table_name = self._get_temp_table(old_table_name)
+        self._rename_table(old_table_name, throwaway_table_name)
+        self._rename_table(new_table_name, old_table_name)
+        self.drop_table(throwaway_table_name)

Happy to open a PR with a unit test covering the three branches (success, NOT_IMPLEMENTED
rename, re-raise). We currently work around it with an engine-adapter subclass that re-raises.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions