test_datasource_pool_failures.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. """Opt-in Docker outage, recovery and credential-rotation acceptance test."""
  2. import os
  3. import subprocess
  4. import threading
  5. from dataclasses import replace
  6. from pathlib import Path
  7. import pytest
  8. from sqlalchemy import create_engine, text
  9. from sqlalchemy.engine import make_url
  10. from app.core.data_source.adapters import adapter_for
  11. from app.core.data_source.errors import (
  12. DataSourceCircuitOpen,
  13. DataSourceConnectionFailed,
  14. )
  15. from app.core.data_source.manager import DataSourceConnectionManager
  16. from app.core.data_source.models import (
  17. DataSourceCredential,
  18. DataSourceDefinition,
  19. )
  20. from app.core.data_source.pool_registry import PoolRegistry
  21. ROOT = Path(__file__).resolve().parents[2]
  22. COMPOSE = ROOT / "deploy/docker/docker-compose.yml"
  23. POSTGRES_UID = "01900000-0000-7000-8000-000000000201"
  24. MYSQL_UID = "01900000-0000-7000-8000-000000000202"
  25. pytestmark = pytest.mark.integration
  26. class FakeClock:
  27. def __init__(self):
  28. self._now = 1000.0
  29. self._lock = threading.Lock()
  30. def __call__(self):
  31. with self._lock:
  32. return self._now
  33. def advance(self, seconds):
  34. with self._lock:
  35. self._now += seconds
  36. class Definitions:
  37. def __init__(self, values):
  38. self.values = {item.uid: item for item in values}
  39. def get(self, uid):
  40. return self.values.get(uid)
  41. def rotate(self, uid):
  42. self.values[uid] = replace(
  43. self.values[uid],
  44. credential_version=self.values[uid].credential_version + 1,
  45. )
  46. class Credentials:
  47. def __init__(self, values):
  48. self.values = values
  49. def get_active(self, _session, uid, _version):
  50. return self.values[uid]
  51. def _compose(*arguments):
  52. subprocess.run(
  53. ["docker", "compose", "-f", str(COMPOSE), *arguments],
  54. cwd=ROOT,
  55. check=True,
  56. text=True,
  57. capture_output=True,
  58. )
  59. def _definition(uid, database_type, raw_url):
  60. url = make_url(raw_url)
  61. return DataSourceDefinition(
  62. uid=uid,
  63. name_en=f"outage-{database_type}",
  64. database_type=database_type,
  65. host=url.host,
  66. port=url.port,
  67. database=url.database,
  68. credential_ref=uid,
  69. credential_version=1,
  70. pool_size=2,
  71. max_overflow=3,
  72. )
  73. def _credentials(raw_url):
  74. url = make_url(raw_url)
  75. return DataSourceCredential(url.username, url.password)
  76. def _build_manager():
  77. postgres_url = os.environ.get(
  78. "TEST_SOURCE_POSTGRES_URL",
  79. "postgresql://source_reader:source-test-password"
  80. "@127.0.0.1:25432/acceptance",
  81. )
  82. mysql_url = os.environ.get(
  83. "TEST_SOURCE_MYSQL_URL",
  84. "mysql+pymysql://source_reader:source-test-password"
  85. "@127.0.0.1:23306/acceptance",
  86. )
  87. definitions = Definitions(
  88. [
  89. _definition(POSTGRES_UID, "postgresql", postgres_url),
  90. _definition(MYSQL_UID, "mysql", mysql_url),
  91. ]
  92. )
  93. credentials = Credentials(
  94. {
  95. POSTGRES_UID: _credentials(postgres_url),
  96. MYSQL_UID: _credentials(mysql_url),
  97. }
  98. )
  99. clock = FakeClock()
  100. settings = {
  101. "pool_size": 2,
  102. "max_overflow": 3,
  103. "pool_timeout": 1,
  104. "pool_recycle": 1800,
  105. "idle_ttl": 900,
  106. "max_idle_pools": 20,
  107. "drain_timeout": 30,
  108. "query_timeout": 5,
  109. }
  110. registry = PoolRegistry(settings, clock=clock)
  111. manager = DataSourceConnectionManager(
  112. definitions=definitions,
  113. credentials=credentials,
  114. platform_session=lambda: object(),
  115. adapter_resolver=adapter_for,
  116. registry=registry,
  117. settings_resolver=lambda overrides: {
  118. **settings,
  119. **overrides,
  120. },
  121. clock=clock,
  122. )
  123. return definitions, clock, registry, manager
  124. def _query_count(manager, uid):
  125. with manager.connect(uid, purpose="dataflow_read") as connection:
  126. return connection.execute(
  127. text("SELECT COUNT(*) FROM acceptance_customers")
  128. ).scalar_one()
  129. def _platform_database_is_healthy():
  130. url = os.environ.get(
  131. "TEST_DATABASE_URL",
  132. "postgresql://dataops:dataops-test-password"
  133. "@127.0.0.1:15432/dataops",
  134. )
  135. engine = create_engine(url, pool_pre_ping=True)
  136. try:
  137. with engine.connect() as connection:
  138. return connection.execute(text("SELECT 1")).scalar_one() == 1
  139. finally:
  140. engine.dispose()
  141. def test_mysql_outage_is_isolated_and_recovers_before_credential_rotation():
  142. if os.environ.get("RUN_DATASOURCE_OUTAGE_TEST") != "1":
  143. pytest.skip("set RUN_DATASOURCE_OUTAGE_TEST=1 to control Docker")
  144. _compose("up", "-d", "--wait", "source-postgres", "source-mysql")
  145. definitions, clock, registry, manager = _build_manager()
  146. try:
  147. assert _query_count(manager, POSTGRES_UID) == 2
  148. assert _query_count(manager, MYSQL_UID) == 2
  149. old_mysql_engine = next(
  150. entry.engine
  151. for entry in registry._entries.values()
  152. if entry.key.data_source_uid == MYSQL_UID
  153. )
  154. _compose("stop", "source-mysql")
  155. for _attempt in range(3):
  156. with pytest.raises(DataSourceConnectionFailed):
  157. _query_count(manager, MYSQL_UID)
  158. with pytest.raises(DataSourceCircuitOpen):
  159. _query_count(manager, MYSQL_UID)
  160. assert _query_count(manager, POSTGRES_UID) == 2
  161. assert _platform_database_is_healthy() is True
  162. _compose("up", "-d", "--wait", "source-mysql")
  163. clock.advance(30)
  164. assert _query_count(manager, MYSQL_UID) == 2
  165. mysql_status = next(
  166. item
  167. for item in registry.snapshot()
  168. if item.data_source_uid == MYSQL_UID
  169. )
  170. assert mysql_status.pool_state == "healthy"
  171. definitions.rotate(MYSQL_UID)
  172. assert _query_count(manager, MYSQL_UID) == 2
  173. assert old_mysql_engine._dataops_disposed is True
  174. rotated_status = next(
  175. item
  176. for item in registry.snapshot()
  177. if item.data_source_uid == MYSQL_UID
  178. )
  179. assert rotated_status.credential_version == 2
  180. finally:
  181. manager.close()
  182. _compose("up", "-d", "--wait", "source-mysql")