Skip to content

Commit b5bdd69

Browse files
authored
Fix(Fabric): Skip Catalog Jump (#5780)
Signed-off-by: Andreas Fredhøi <andreas.fredhoi@fresio.no>
1 parent e689df5 commit b5bdd69

2 files changed

Lines changed: 240 additions & 32 deletions

File tree

sqlmesh/core/engine_adapter/fabric.py

Lines changed: 95 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,33 @@ def _target_catalog(self) -> t.Optional[str]:
5858
def _target_catalog(self, value: t.Optional[str]) -> None:
5959
self._connection_pool.set_attribute("target_catalog", value)
6060

61+
@property
62+
def _connected_catalog(self) -> t.Optional[str]:
63+
"""Catalog the currently-open thread-local connection is actually using."""
64+
return self._connection_pool.get_attribute("connected_catalog")
65+
66+
@_connected_catalog.setter
67+
def _connected_catalog(self, value: t.Optional[str]) -> None:
68+
self._connection_pool.set_attribute("connected_catalog", value)
69+
70+
def _normalize_catalog(self, catalog_name: t.Optional[str]) -> t.Optional[str]:
71+
if not catalog_name:
72+
return None
73+
74+
default_catalog = self._default_catalog or self._extra_config.get("database")
75+
if default_catalog and catalog_name == default_catalog:
76+
return None
77+
78+
return catalog_name
79+
80+
def _catalog_state_label(self, catalog_name: t.Optional[str]) -> str:
81+
return (
82+
catalog_name
83+
or self._default_catalog
84+
or self._extra_config.get("database")
85+
or "<default>"
86+
)
87+
6188
@property
6289
def api_client(self) -> FabricHttpClient:
6390
# the requests Session is not guaranteed to be threadsafe
@@ -101,20 +128,28 @@ def _create_catalog(self, catalog_name: exp.Identifier) -> None:
101128
def _drop_catalog(self, catalog_name: exp.Identifier) -> None:
102129
"""Drop a catalog (warehouse) in Microsoft Fabric via REST API."""
103130
warehouse_name = catalog_name.sql(dialect=self.dialect, identify=False)
104-
current_catalog = self.get_current_catalog()
105131

106132
logger.info(f"Deleting Fabric warehouse: {warehouse_name}")
107133
self.api_client.delete_warehouse(warehouse_name)
108134

109-
if warehouse_name == current_catalog:
110-
# Somewhere around 2025-09-08, Fabric started validating the "Database=" connection argument and throwing 'Authentication failed' if the database doesnt exist
111-
# In addition, set_current_catalog() is implemented using a threadlocal variable "target_catalog"
112-
# So, when we drop a warehouse, and there are still threads with "target_catalog" set to reference it, any operations on those threads
113-
# that use an either use an existing connection pointing to this warehouse or trigger a new connection
114-
# will fail with an 'Authentication Failed' error unless we close all connections here, which also clears all the threadlocal data
135+
# Close all connections if any thread may be using the dropped warehouse.
136+
# We must check both the logical target and the physical connection catalog
137+
# (falling back to the configured default when either is neutral) because
138+
# Fabric validates the DATABASE= connection argument and raises
139+
# 'Authentication Failed' when it points at a non-existent warehouse.
140+
default_db = self._extra_config.get("database")
141+
in_use = {
142+
self.get_current_catalog() or default_db,
143+
self._normalize_catalog(self._connected_catalog) or default_db,
144+
}
145+
if warehouse_name in in_use:
115146
self.close()
116147

117-
def set_current_catalog(self, catalog_name: str) -> None:
148+
def get_current_catalog(self) -> t.Optional[str]:
149+
"""Return the explicit Fabric catalog target for the current thread."""
150+
return self._normalize_catalog(self._target_catalog)
151+
152+
def set_current_catalog(self, catalog_name: t.Optional[str]) -> None:
118153
"""
119154
Set the current catalog for Microsoft Fabric connections.
120155
@@ -123,7 +158,8 @@ def set_current_catalog(self, catalog_name: str) -> None:
123158
recreate them with the new catalog in the connection configuration.
124159
125160
Args:
126-
catalog_name: The name of the catalog (warehouse) to switch to
161+
catalog_name: The name of the catalog (warehouse) to switch to.
162+
The configured default catalog is treated as the neutral state.
127163
128164
Note:
129165
Fabric doesn't support catalog switching via USE statements because each
@@ -133,33 +169,60 @@ def set_current_catalog(self, catalog_name: str) -> None:
133169
See:
134170
https://learn.microsoft.com/en-us/fabric/data-warehouse/sql-query-editor#limitations
135171
"""
136-
current_catalog = self.get_current_catalog()
137-
138-
# If already using the requested catalog, do nothing
139-
if current_catalog and current_catalog == catalog_name:
140-
logger.debug(f"Already using catalog '{catalog_name}', no action needed")
172+
target_catalog = self._normalize_catalog(catalog_name)
173+
explicit_default_catalog = catalog_name is not None and target_catalog is None
174+
connected_catalog = self._normalize_catalog(self._connected_catalog)
175+
176+
# An explicit request for the default catalog must also match the catalog
177+
# used by the open connection. A lazy restore with None only updates the
178+
# logical target and intentionally leaves that connection in place.
179+
if self.get_current_catalog() == target_catalog and (
180+
not explicit_default_catalog or connected_catalog is None
181+
):
182+
logger.debug("Already using requested Fabric catalog state, no action needed")
141183
return
142184

143-
logger.info(f"Switching from catalog '{current_catalog}' to '{catalog_name}'")
144-
145-
# commit the transaction before closing the connection to help prevent errors like:
146-
# > Snapshot isolation transaction failed in database because the object accessed by the statement has been modified by a
147-
# > DDL statement in another concurrent transaction since the start of this transaction
148-
# on subsequent queries in the new connection
149-
self._connection_pool.commit()
150-
151-
# note: we call close() on the connection pool instead of self.close() because self.close() calls close_all()
152-
# on the connection pool but we just want to close the connection for this thread
153-
self._connection_pool.close()
154-
self._target_catalog = catalog_name # new connections will use this catalog
155-
156-
catalog_after_switch = self.get_current_catalog()
185+
# Decide whether the open connection needs to be replaced.
186+
#
187+
# The set_catalog decorator restores the previous catalog (often None)
188+
# after every catalog-scoped call. For Fabric, a connection close +
189+
# reopen is expensive because each new connection goes through ODBC and
190+
# the Fabric gateway. We therefore apply lazy connection management:
191+
#
192+
# * When restoring to neutral (target=None): just update _target_catalog.
193+
# The existing connection stays alive and will be reused or replaced
194+
# on the next real switch, avoiding a pointless bounce through the
195+
# default catalog.
196+
#
197+
# * When switching to a non-neutral catalog: only close/reopen if the
198+
# open connection is already on a different catalog. If a previous
199+
# restore-to-neutral left the connection on the right catalog, we
200+
# skip the close entirely.
201+
needs_reconnect = (target_catalog is not None or explicit_default_catalog) and (
202+
connected_catalog != target_catalog
203+
)
157204

158-
if catalog_after_switch != catalog_name:
159-
# We need to raise an error if the catalog switch failed to prevent the operation that needed the catalog switch from being run against the wrong catalog
160-
raise SQLMeshError(
161-
f"Unable to switch catalog to {catalog_name}, catalog ended up as {catalog_after_switch}"
205+
if needs_reconnect:
206+
logger.info(
207+
"Switching connection from catalog '%s' to '%s'",
208+
self._catalog_state_label(connected_catalog),
209+
self._catalog_state_label(target_catalog),
162210
)
211+
# Commit before closing to avoid snapshot-isolation errors on
212+
# subsequent queries in the new connection.
213+
self._connection_pool.commit()
214+
# note: close() on the pool (not self.close()) to only affect this
215+
# thread's connection rather than all threads.
216+
self._connection_pool.close()
217+
self._connected_catalog = target_catalog
218+
else:
219+
logger.debug(
220+
"Updating catalog target to '%s' (connection remains on '%s')",
221+
self._catalog_state_label(target_catalog),
222+
self._catalog_state_label(connected_catalog),
223+
)
224+
225+
self._target_catalog = target_catalog
163226

164227
def alter_table(
165228
self, alter_expressions: t.Union[t.List[exp.Alter], t.List[TableAlterOperation]]

tests/core/engine_adapter/test_fabric.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,151 @@ def adapter(make_mocked_engine_adapter: t.Callable) -> FabricEngineAdapter:
1919
return make_mocked_engine_adapter(FabricEngineAdapter)
2020

2121

22+
def test_get_current_catalog_uses_only_explicit_target_catalog(
23+
make_mocked_engine_adapter: t.Callable,
24+
):
25+
adapter = make_mocked_engine_adapter(
26+
FabricEngineAdapter,
27+
database="default_catalog",
28+
)
29+
30+
assert adapter.get_current_catalog() is None
31+
32+
adapter._target_catalog = "switched_catalog"
33+
34+
assert adapter.get_current_catalog() == "switched_catalog"
35+
36+
adapter._connection_pool.close()
37+
38+
assert adapter._connection_pool.get_attribute("target_catalog") is None
39+
assert adapter.get_current_catalog() is None
40+
adapter.cursor.execute.assert_not_called()
41+
42+
43+
def test_get_current_catalog_returns_none_without_target_or_database(
44+
make_mocked_engine_adapter: t.Callable,
45+
):
46+
adapter = make_mocked_engine_adapter(FabricEngineAdapter)
47+
48+
assert adapter.get_current_catalog() is None
49+
adapter.cursor.execute.assert_not_called()
50+
51+
52+
def test_set_current_catalog_does_not_query_database(
53+
make_mocked_engine_adapter: t.Callable,
54+
):
55+
adapter = make_mocked_engine_adapter(
56+
FabricEngineAdapter,
57+
database="default_catalog",
58+
)
59+
60+
adapter.set_current_catalog("new_catalog")
61+
62+
assert adapter.get_current_catalog() == "new_catalog"
63+
adapter.cursor.execute.assert_not_called()
64+
65+
66+
def test_set_current_catalog_to_default_clears_explicit_target(
67+
make_mocked_engine_adapter: t.Callable,
68+
):
69+
adapter = make_mocked_engine_adapter(
70+
FabricEngineAdapter,
71+
default_catalog="core",
72+
database="core",
73+
)
74+
75+
adapter.set_current_catalog("planning")
76+
adapter.set_current_catalog("core")
77+
78+
assert adapter.get_current_catalog() is None
79+
adapter.cursor.execute.assert_not_called()
80+
81+
82+
def test_catalog_scoped_call_restores_to_neutral_without_close(
83+
make_mocked_engine_adapter: t.Callable,
84+
mocker: MockerFixture,
85+
):
86+
"""Decorator's restore-to-neutral must not close the existing connection."""
87+
adapter = make_mocked_engine_adapter(
88+
FabricEngineAdapter,
89+
default_catalog="core",
90+
database="core",
91+
)
92+
close_spy = mocker.spy(adapter._connection_pool, "close")
93+
adapter.cursor.fetchone.return_value = (1,)
94+
95+
adapter.table_exists("planning.db.table")
96+
97+
# Decorator calls set_current_catalog("planning") then set_current_catalog(None).
98+
# Only the first call (None→planning) should trigger a connection close.
99+
assert close_spy.call_count == 1
100+
assert adapter._connected_catalog == "planning"
101+
assert adapter.get_current_catalog() is None
102+
103+
104+
def test_default_catalog_after_non_default_catalog_reconnects(
105+
make_mocked_engine_adapter: t.Callable,
106+
mocker: MockerFixture,
107+
):
108+
adapter = make_mocked_engine_adapter(
109+
FabricEngineAdapter,
110+
default_catalog="core",
111+
database="core",
112+
)
113+
close_spy = mocker.spy(adapter._connection_pool, "close")
114+
adapter.cursor.fetchone.return_value = (1,)
115+
116+
adapter.table_exists("planning.db.table")
117+
adapter.table_exists("core.db.table")
118+
119+
assert close_spy.call_count == 2
120+
assert adapter._connected_catalog is None
121+
assert adapter.get_current_catalog() is None
122+
123+
124+
def test_repeated_same_catalog_reuses_connection(
125+
make_mocked_engine_adapter: t.Callable,
126+
mocker: MockerFixture,
127+
):
128+
"""Two consecutive operations on the same catalog share one connection."""
129+
adapter = make_mocked_engine_adapter(
130+
FabricEngineAdapter,
131+
default_catalog="core",
132+
database="core",
133+
)
134+
close_spy = mocker.spy(adapter._connection_pool, "close")
135+
adapter.cursor.fetchone.return_value = (1,)
136+
137+
adapter.table_exists("planning.db.table")
138+
adapter.table_exists("planning.db.table")
139+
140+
# Only the very first switch (None→planning) should close.
141+
# The restore to neutral keeps the connection alive and the second
142+
# planning operation reuses it without another close.
143+
assert close_spy.call_count == 1
144+
assert adapter._connected_catalog == "planning"
145+
146+
147+
def test_switching_between_catalogs_closes_each_time(
148+
make_mocked_engine_adapter: t.Callable,
149+
mocker: MockerFixture,
150+
):
151+
"""Switching to a different catalog always triggers a connection close."""
152+
adapter = make_mocked_engine_adapter(
153+
FabricEngineAdapter,
154+
default_catalog="core",
155+
database="core",
156+
)
157+
close_spy = mocker.spy(adapter._connection_pool, "close")
158+
adapter.cursor.fetchone.return_value = (1,)
159+
160+
adapter.table_exists("safran.db.table") # None→safran: 1 close
161+
adapter.table_exists("planning.db.table") # safran→planning: 2nd close
162+
163+
assert close_spy.call_count == 2
164+
assert adapter._connected_catalog == "planning"
165+
166+
22167
def test_columns(adapter: FabricEngineAdapter):
23168
adapter.cursor.fetchall.return_value = [
24169
("decimal_ps", "decimal", None, 5, 4),

0 commit comments

Comments
 (0)