Skip to content

Commit 3748bba

Browse files
ulixius9claude
andcommitted
fix(ingestion): release engine resources on database switch
Close fairies in _connection_map and clear _inspector_map before engine.dispose() in CommonDbSourceService.set_inspector/close. Dispose alone does not free Inspector.info_cache or release checked-out ConnectionFairies, leaving the old engine GC-pinned across DB switches and triggering _finalize_fairy RecursionError at interpreter shutdown. Eagerly fetch multi-DB name queries (MultiDBSource._execute_database_query and SnowflakeSource.get_database_names_raw) so the cursor closes before the caller invokes set_inspector, which disposes the engine the cursor was bound to. Also rebind scoped_session to the new engine so it doesn't keep the disposed one alive via sessionmaker.bind. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 285eb8a commit 3748bba

5 files changed

Lines changed: 287 additions & 10 deletions

File tree

ingestion/src/metadata/ingestion/source/database/common_db_source.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@
6262
from metadata.ingestion.models.patch_request import PatchedEntity, PatchRequest
6363
from metadata.ingestion.ometa.ometa_api import OpenMetadata
6464
from metadata.ingestion.source.connections import get_connection
65-
from metadata.ingestion.source.connections_utils import kill_active_connections
6665
from metadata.ingestion.source.database.database_service import DatabaseServiceSource
6766
from metadata.ingestion.source.database.sql_column_handler import SqlColumnHandlerMixin
6867
from metadata.ingestion.source.database.sqlalchemy_source import SqlAlchemySource
@@ -152,15 +151,39 @@ def set_inspector(self, database_name: str) -> None:
152151
:param database_name: new database to set
153152
"""
154153

155-
kill_active_connections(self.engine)
154+
self._release_engine()
156155
logger.info(f"Ingesting from database: {database_name}")
157156

158157
new_service_connection = deepcopy(self.service_connection)
159158
new_service_connection.database = database_name
160159
self.engine = get_connection(new_service_connection)
160+
self.session = create_and_bind_thread_safe_session(self.engine)
161161

162-
self._connection_map = {} # Lazy init as well
162+
def _release_engine(self) -> None:
163+
# Close fairies first so _ConnectionRecord drops its pool reference;
164+
# dispose alone leaves them orphaned and causes _finalize_fairy
165+
# RecursionErrors at GC time. Clearing _inspector_map is what
166+
# actually frees Inspector.info_cache — dispose() does not.
167+
if getattr(self, "engine", None) is None:
168+
return
169+
for conn in self._connection_map.values():
170+
try:
171+
conn.close()
172+
except Exception: # pylint: disable=broad-except
173+
logger.debug("Connection already closed", exc_info=True)
174+
self._connection_map = {}
163175
self._inspector_map = {}
176+
session = getattr(self, "session", None)
177+
if session is not None:
178+
try:
179+
session.remove()
180+
except Exception: # pylint: disable=broad-except
181+
logger.debug("Session cleanup failed", exc_info=True)
182+
self.session = None
183+
try:
184+
self.engine.dispose()
185+
except Exception as exc: # pylint: disable=broad-except
186+
logger.warning(f"Failed to dispose engine: {exc}")
164187

165188
def get_database_names(self) -> Iterable[str]:
166189
"""
@@ -780,14 +803,10 @@ def inspector(self) -> Inspector:
780803
return self._inspector_map[thread_id]
781804

782805
def close(self):
783-
if self.connection is not None:
784-
self.connection.close()
785-
for connection in self._connection_map.values():
786-
connection.close()
806+
self._release_engine()
787807
if hasattr(self, "ssl_manager") and self.ssl_manager:
788808
self.ssl_manager = cast(SSLManager, self.ssl_manager)
789809
self.ssl_manager.cleanup_temp_files()
790-
self.engine.dispose()
791810

792811
def fetch_table_tags(
793812
self,

ingestion/src/metadata/ingestion/source/database/multi_db_source.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def get_database_names_raw(self) -> Iterable[str]:
3333
"""
3434

3535
def _execute_database_query(self, query: str) -> Iterable[str]:
36-
results = self.connection.execute(text(query)) # pylint: disable=no-member
36+
results = self.connection.execute(text(query)).fetchall() # pylint: disable=no-member
3737
for res in results:
3838
row = list(res)
3939
yield row[0]

ingestion/src/metadata/ingestion/source/database/snowflake/metadata.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,7 @@ def get_configured_database(self) -> Optional[str]:
402402
return self.service_connection.database
403403

404404
def get_database_names_raw(self) -> Iterable[str]:
405-
results = self.connection.execute(text(SNOWFLAKE_GET_DATABASES))
405+
results = self.connection.execute(text(SNOWFLAKE_GET_DATABASES)).fetchall()
406406
for res in results:
407407
row = list(res)
408408
yield row[1]

ingestion/tests/unit/topology/database/test_common_db_source.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,15 @@
1313
Tests for CommonDbSourceService._prepare_foreign_constraints
1414
"""
1515

16+
import gc
17+
import weakref
1618
from unittest.mock import MagicMock, patch
1719

1820
import pytest
21+
from sqlalchemy import create_engine, text
22+
from sqlalchemy.engine import Engine
23+
from sqlalchemy.inspection import inspect
24+
from sqlalchemy.pool import QueuePool
1925

2026
from metadata.generated.schema.entity.data.table import (
2127
Column,
@@ -24,8 +30,10 @@
2430
Table,
2531
TableConstraint,
2632
)
33+
from metadata.ingestion.connections.session import create_and_bind_thread_safe_session
2734
from metadata.ingestion.source.database.common_db_source import CommonDbSourceService
2835
from metadata.ingestion.source.database.database_service import DatabaseServiceSource
36+
from metadata.ingestion.source.database.multi_db_source import MultiDBSource
2937

3038

3139
@pytest.fixture
@@ -334,3 +342,207 @@ def test_constraint_with_none_columns_skipped(self):
334342
result = DatabaseServiceSource.normalize_table_constraints(constraints, columns)
335343
assert result[0].columns is None
336344
assert result[1].columns == ["id"]
345+
346+
347+
class _ReleaseOnlySurrogate(CommonDbSourceService):
348+
"""
349+
Minimal concrete subclass that bypasses CommonDbSourceService.__init__
350+
(which needs a full workflow config) so we can drive _release_engine /
351+
close against a real SQLAlchemy engine in isolation.
352+
"""
353+
354+
def __init__(self, engine=None): # pylint: disable=super-init-not-called
355+
self.engine = engine
356+
self._connection_map = {}
357+
self._inspector_map = {}
358+
self.session = None
359+
self.ssl_manager = None
360+
361+
def create(self, *args, **kwargs): # satisfy abstract method contract
362+
raise NotImplementedError
363+
364+
365+
def _make_release_surrogate(engine=None):
366+
"""
367+
Build a minimal stand-in for CommonDbSourceService that has just the
368+
attributes _release_engine touches, bypassing the heavy __init__ that
369+
requires a full workflow config.
370+
"""
371+
return _ReleaseOnlySurrogate(engine=engine)
372+
373+
374+
@pytest.fixture
375+
def sqlite_engine():
376+
"""Real, in-memory SQLite engine with an explicit QueuePool."""
377+
engine = create_engine("sqlite:///:memory:", poolclass=QueuePool)
378+
yield engine
379+
try:
380+
engine.dispose()
381+
except Exception:
382+
pass
383+
384+
385+
@pytest.fixture
386+
def surrogate(sqlite_engine):
387+
"""Minimal CommonDbSourceService with a real engine attached."""
388+
return _make_release_surrogate(sqlite_engine)
389+
390+
391+
class TestReleaseEngine:
392+
"""Option B: _release_engine closes all pooled connections, clears
393+
inspector/session state, and disposes the engine regardless of
394+
which thread called it."""
395+
396+
def test_closes_every_connection_map_entry(self, surrogate):
397+
conn_a = surrogate.engine.connect()
398+
conn_b = surrogate.engine.connect()
399+
surrogate._connection_map[111] = conn_a
400+
surrogate._connection_map[222] = conn_b
401+
402+
surrogate._release_engine()
403+
404+
assert conn_a.closed is True
405+
assert conn_b.closed is True
406+
assert surrogate._connection_map == {}
407+
408+
def test_clears_inspector_map(self, surrogate):
409+
surrogate._connection_map[999] = surrogate.engine.connect()
410+
surrogate._inspector_map[999] = inspect(surrogate._connection_map[999])
411+
assert len(surrogate._inspector_map) == 1
412+
413+
surrogate._release_engine()
414+
415+
assert surrogate._inspector_map == {}
416+
417+
def test_disposes_pool(self, surrogate):
418+
original_pool = surrogate.engine.pool
419+
assert isinstance(original_pool, QueuePool)
420+
421+
surrogate._release_engine()
422+
423+
assert surrogate.engine.pool is not original_pool
424+
425+
def test_removes_session(self, surrogate):
426+
surrogate.session = create_and_bind_thread_safe_session(surrogate.engine)
427+
assert surrogate.session is not None
428+
429+
surrogate._release_engine()
430+
431+
assert surrogate.session is None
432+
433+
def test_idempotent_when_engine_is_none(self):
434+
surrogate = _make_release_surrogate(engine=None)
435+
surrogate._release_engine()
436+
assert surrogate.engine is None
437+
assert surrogate._connection_map == {}
438+
assert surrogate._inspector_map == {}
439+
440+
def test_tolerates_already_closed_connection(self, surrogate):
441+
healthy = surrogate.engine.connect()
442+
already_closed = surrogate.engine.connect()
443+
already_closed.close()
444+
surrogate._connection_map[1] = healthy
445+
surrogate._connection_map[2] = already_closed
446+
447+
surrogate._release_engine()
448+
449+
assert healthy.closed is True
450+
assert surrogate._connection_map == {}
451+
452+
def test_closes_connections_from_arbitrary_thread_ids(self, surrogate):
453+
"""Key property of Option B: close-all, not detach-current-thread.
454+
Every fairy in _connection_map must close regardless of the caller's
455+
thread id."""
456+
conns = {
457+
111: surrogate.engine.connect(),
458+
222: surrogate.engine.connect(),
459+
333: surrogate.engine.connect(),
460+
}
461+
surrogate._connection_map.update(conns)
462+
463+
surrogate._release_engine()
464+
465+
for conn in conns.values():
466+
assert conn.closed is True
467+
assert surrogate._connection_map == {}
468+
469+
470+
class TestEngineGcReclamation:
471+
"""Acceptance test for the memory leak fix: after _release_engine and
472+
dropping the strong reference, the old Engine must be garbage-collectable.
473+
The previous kill_active_connections path left _ConnectionRecord fairies
474+
pinning the engine, which is what this test guards against."""
475+
476+
def test_old_engine_becomes_gc_eligible_after_release(self):
477+
engine = create_engine("sqlite:///:memory:", poolclass=QueuePool)
478+
surrogate = _make_release_surrogate(engine)
479+
surrogate._connection_map[12345] = surrogate.engine.connect()
480+
481+
old_engine_ref = weakref.ref(surrogate.engine)
482+
483+
surrogate._release_engine()
484+
surrogate.engine = None
485+
engine = None # drop local strong ref too
486+
487+
gc.collect()
488+
489+
assert old_engine_ref() is None
490+
491+
492+
class _FakeSource(MultiDBSource):
493+
"""Minimal MultiDBSource that exposes a real SQLAlchemy connection so we
494+
can exercise _execute_database_query against a live cursor."""
495+
496+
def __init__(self, engine: Engine):
497+
self._engine = engine
498+
self._conn = engine.connect()
499+
500+
@property
501+
def connection(self):
502+
return self._conn
503+
504+
def get_configured_database(self):
505+
return None
506+
507+
def get_database_names_raw(self):
508+
return self._execute_database_query("SELECT id FROM dbs ORDER BY id")
509+
510+
511+
class TestExecuteDatabaseQueryEagerFetch:
512+
"""Option B Part 2: _execute_database_query must eagerly .fetchall()
513+
so that engine.dispose() firing mid-iteration (the original regression
514+
pattern from set_inspector) does not invalidate the cursor."""
515+
516+
@pytest.fixture
517+
def seeded_engine(self):
518+
engine = create_engine("sqlite:///:memory:", poolclass=QueuePool)
519+
with engine.connect() as conn:
520+
conn.execute(text("CREATE TABLE dbs (id INTEGER PRIMARY KEY, name TEXT)"))
521+
conn.execute(
522+
text("INSERT INTO dbs(id, name) VALUES (1, 'alpha'), (2, 'beta'), (3, 'gamma')")
523+
)
524+
conn.commit()
525+
yield engine
526+
try:
527+
engine.dispose()
528+
except Exception:
529+
pass
530+
531+
def test_generator_survives_engine_dispose_mid_iteration(self, seeded_engine):
532+
source = _FakeSource(seeded_engine)
533+
generator = source.get_database_names_raw()
534+
535+
first = next(generator)
536+
assert first == 1
537+
538+
seeded_engine.dispose()
539+
540+
remaining = list(generator)
541+
assert remaining == [2, 3]
542+
543+
def test_returns_all_rows_in_order(self, seeded_engine):
544+
source = _FakeSource(seeded_engine)
545+
546+
results = list(source.get_database_names_raw())
547+
548+
assert results == [1, 2, 3]

ingestion/tests/unit/topology/database/test_snowflake.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,3 +853,49 @@ def test_empty_tag_value_skipped_with_warning(self):
853853
source.schema_tags_map["TEST_SCHEMA"][0],
854854
{"tag_name": "TEST_TAG", "tag_value": "123"},
855855
)
856+
857+
858+
class TestSnowflakeGetDatabaseNamesRawEagerFetch:
859+
"""
860+
Option B Part 2 applied to Snowflake: get_database_names_raw must call
861+
.fetchall() so that a subsequent engine.dispose() / set_inspector does
862+
not invalidate the cursor mid-iteration.
863+
"""
864+
865+
@staticmethod
866+
def _build_mock_rows():
867+
return [
868+
["row_meta", "DB_A"],
869+
["row_meta", "DB_B"],
870+
["row_meta", "DB_C"],
871+
]
872+
873+
def test_fetchall_invoked_exactly_once(self):
874+
source = SnowflakeSource.__new__(SnowflakeSource)
875+
result = MagicMock()
876+
result.fetchall.return_value = self._build_mock_rows()
877+
mock_conn = MagicMock()
878+
mock_conn.execute.return_value = result
879+
880+
with patch.object(
881+
SnowflakeSource, "connection", new_callable=PropertyMock
882+
) as mocked_conn_prop:
883+
mocked_conn_prop.return_value = mock_conn
884+
list(source.get_database_names_raw())
885+
886+
assert result.fetchall.call_count == 1
887+
888+
def test_yields_database_names_in_order(self):
889+
source = SnowflakeSource.__new__(SnowflakeSource)
890+
result = MagicMock()
891+
result.fetchall.return_value = self._build_mock_rows()
892+
mock_conn = MagicMock()
893+
mock_conn.execute.return_value = result
894+
895+
with patch.object(
896+
SnowflakeSource, "connection", new_callable=PropertyMock
897+
) as mocked_conn_prop:
898+
mocked_conn_prop.return_value = mock_conn
899+
names = list(source.get_database_names_raw())
900+
901+
assert names == ["DB_A", "DB_B", "DB_C"]

0 commit comments

Comments
 (0)