|
13 | 13 | Tests for CommonDbSourceService._prepare_foreign_constraints |
14 | 14 | """ |
15 | 15 |
|
| 16 | +import gc |
| 17 | +import weakref |
16 | 18 | from unittest.mock import MagicMock, patch |
17 | 19 |
|
18 | 20 | 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 |
19 | 25 |
|
20 | 26 | from metadata.generated.schema.entity.data.table import ( |
21 | 27 | Column, |
|
24 | 30 | Table, |
25 | 31 | TableConstraint, |
26 | 32 | ) |
| 33 | +from metadata.ingestion.connections.session import create_and_bind_thread_safe_session |
27 | 34 | from metadata.ingestion.source.database.common_db_source import CommonDbSourceService |
28 | 35 | from metadata.ingestion.source.database.database_service import DatabaseServiceSource |
| 36 | +from metadata.ingestion.source.database.multi_db_source import MultiDBSource |
29 | 37 |
|
30 | 38 |
|
31 | 39 | @pytest.fixture |
@@ -334,3 +342,207 @@ def test_constraint_with_none_columns_skipped(self): |
334 | 342 | result = DatabaseServiceSource.normalize_table_constraints(constraints, columns) |
335 | 343 | assert result[0].columns is None |
336 | 344 | 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] |
0 commit comments