test_rule_artifact_migration_upgrade.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. from __future__ import annotations
  2. import logging
  3. import os
  4. import re
  5. from pathlib import Path
  6. import polars as pl
  7. import pytest
  8. from alembic import command
  9. from alembic.config import Config
  10. from sqlalchemy import create_engine, text
  11. from sqlalchemy.exc import IntegrityError
  12. from app.core.common.identifiers import new_governance_uid
  13. from tests.runner.test_artifacts import FakeMinio, _store
  14. ROOT = Path(__file__).resolve().parents[2]
  15. COMPOSE = ROOT / "deploy" / "docker" / "docker-compose.yml"
  16. def _compose_value(pattern: str) -> str:
  17. match = re.search(
  18. pattern,
  19. COMPOSE.read_text(encoding="utf-8"),
  20. flags=re.DOTALL,
  21. )
  22. assert match is not None
  23. return match.group(1)
  24. def _upgrade(database_url: str, revision: str) -> None:
  25. previous = os.environ.get("DATABASE_URL")
  26. root_logger = logging.getLogger()
  27. root_handlers = list(root_logger.handlers)
  28. root_level = root_logger.level
  29. logger_disabled = {
  30. name: logger.disabled
  31. for name, logger in logging.Logger.manager.loggerDict.items()
  32. if isinstance(logger, logging.Logger)
  33. }
  34. os.environ["DATABASE_URL"] = database_url
  35. try:
  36. command.upgrade(Config(str(ROOT / "alembic.ini")), revision)
  37. finally:
  38. root_logger.handlers[:] = root_handlers
  39. root_logger.setLevel(root_level)
  40. for name, disabled in logger_disabled.items():
  41. logging.getLogger(name).disabled = disabled
  42. if previous is None:
  43. os.environ.pop("DATABASE_URL", None)
  44. else:
  45. os.environ["DATABASE_URL"] = previous
  46. def test_old_140_upgrades_to_durable_handoff_and_enforces_cas(tmp_path):
  47. from app.runner.artifacts import PostgresArtifactResolver
  48. platform_user = _compose_value(
  49. r"\n postgres:.*?POSTGRES_USER:\s*([^\s]+)"
  50. )
  51. platform_password = _compose_value(
  52. r"\n postgres:.*?POSTGRES_PASSWORD:\s*([^\s]+)"
  53. )
  54. platform_port = _compose_value(r'"(15432):5432"')
  55. admin_url = (
  56. f"postgresql+psycopg2://{platform_user}:{platform_password}"
  57. f"@127.0.0.1:{platform_port}/postgres"
  58. )
  59. database_name = f"task5_migration_{new_governance_uid().replace('-', '')}"
  60. database_url = (
  61. f"postgresql+psycopg2://{platform_user}:{platform_password}"
  62. f"@127.0.0.1:{platform_port}/{database_name}"
  63. )
  64. admin = create_engine(admin_url, isolation_level="AUTOCOMMIT")
  65. engine = None
  66. try:
  67. with admin.connect() as connection:
  68. connection.execute(text(f'CREATE DATABASE "{database_name}"'))
  69. _upgrade(database_url, "20260723_140")
  70. engine = create_engine(database_url, pool_pre_ping=True)
  71. dataflow_version_id = new_governance_uid()
  72. deployment_id = new_governance_uid()
  73. schema_id = new_governance_uid()
  74. binding_id = new_governance_uid()
  75. binding_hash = "b" * 64
  76. correlation_id = new_governance_uid()
  77. old_artifact_id = new_governance_uid()
  78. old_ref = (
  79. f"minio://dataops-rules/rules/{correlation_id}/"
  80. f"{new_governance_uid()}.parquet"
  81. )
  82. with engine.begin() as connection:
  83. columns_before = {
  84. row[0]
  85. for row in connection.execute(
  86. text(
  87. """
  88. SELECT column_name
  89. FROM information_schema.columns
  90. WHERE table_schema = 'public'
  91. AND table_name = 'rule_run_artifacts'
  92. """
  93. )
  94. )
  95. }
  96. assert "handoff_status" not in columns_before
  97. connection.execute(
  98. text(
  99. """
  100. INSERT INTO public.dataflow_versions (
  101. id, dataflow_uid, version_no, name, dataflow_spec,
  102. input_schema_hashes, output_schema_hash, status
  103. ) VALUES (
  104. CAST(:id AS uuid), CAST(:uid AS uuid), 1, 'migration',
  105. '{}'::jsonb, '[]'::jsonb, :schema_hash, 'released'
  106. )
  107. """
  108. ),
  109. {
  110. "id": dataflow_version_id,
  111. "uid": new_governance_uid(),
  112. "schema_hash": "a" * 64,
  113. },
  114. )
  115. connection.execute(
  116. text(
  117. """
  118. INSERT INTO public.dataflow_deployments (
  119. id, dataflow_version_id, environment,
  120. deployment_config, status
  121. ) VALUES (
  122. CAST(:id AS uuid), CAST(:version_id AS uuid), 'test',
  123. '{}'::jsonb, 'active'
  124. )
  125. """
  126. ),
  127. {"id": deployment_id, "version_id": dataflow_version_id},
  128. )
  129. connection.execute(
  130. text(
  131. """
  132. INSERT INTO public.data_schema_snapshots (
  133. id, schema_ref, schema_hash, fields, source_revision
  134. ) VALUES (
  135. CAST(:id AS uuid), 'migration:id', :schema_hash,
  136. CAST(:fields AS jsonb), 'old-140'
  137. )
  138. """
  139. ),
  140. {
  141. "id": schema_id,
  142. "schema_hash": "a" * 64,
  143. "fields": (
  144. '[{"name":"id","type":"integer",'
  145. '"nullable":false}]'
  146. ),
  147. },
  148. )
  149. connection.execute(
  150. text(
  151. """
  152. INSERT INTO public.dataflow_dataset_bindings (
  153. id, dataflow_deployment_id, logical_ref,
  154. object_kind, object_ref, schema_snapshot_id, dialect,
  155. access_mode, write_mode, binding_hash
  156. ) VALUES (
  157. CAST(:id AS uuid), CAST(:deployment_id AS uuid),
  158. 'output', 'parquet_artifact', 'migration-output',
  159. CAST(:schema_id AS uuid), 'parquet', 'write',
  160. 'append', :binding_hash
  161. )
  162. """
  163. ),
  164. {
  165. "id": binding_id,
  166. "deployment_id": deployment_id,
  167. "schema_id": schema_id,
  168. "binding_hash": binding_hash,
  169. },
  170. )
  171. connection.execute(
  172. text(
  173. """
  174. INSERT INTO public.rule_run_artifacts (
  175. id, correlation_id, binding_id, artifact_ref,
  176. artifact_digest, row_count, schema_hash, schema_fields,
  177. artifact_kind, expires_at
  178. ) VALUES (
  179. CAST(:id AS uuid), CAST(:correlation_id AS uuid),
  180. CAST(:binding_id AS uuid), :artifact_ref,
  181. :digest, 1, :schema_hash, CAST(:fields AS jsonb),
  182. 'output', CURRENT_TIMESTAMP + INTERVAL '1 hour'
  183. )
  184. """
  185. ),
  186. {
  187. "id": old_artifact_id,
  188. "correlation_id": correlation_id,
  189. "binding_id": binding_id,
  190. "artifact_ref": old_ref,
  191. "digest": "d" * 64,
  192. "schema_hash": "a" * 64,
  193. "fields": (
  194. '[{"name":"id","type":"integer",'
  195. '"nullable":false}]'
  196. ),
  197. },
  198. )
  199. engine.dispose()
  200. engine = None
  201. _upgrade(database_url, "head")
  202. engine = create_engine(database_url, pool_pre_ping=True)
  203. with engine.begin() as connection:
  204. migrated = connection.execute(
  205. text(
  206. """
  207. SELECT binding_hash, handoff_status, ready_at
  208. FROM public.rule_run_artifacts
  209. WHERE id = CAST(:id AS uuid)
  210. """
  211. ),
  212. {"id": old_artifact_id},
  213. ).mappings().one()
  214. assert migrated["binding_hash"] == binding_hash
  215. assert migrated["handoff_status"] == "ready"
  216. assert migrated["ready_at"] is not None
  217. connection.execute(
  218. text(
  219. """
  220. DELETE FROM public.rule_run_artifacts
  221. WHERE id = CAST(:id AS uuid)
  222. """
  223. ),
  224. {"id": old_artifact_id},
  225. )
  226. store = _store(FakeMinio())
  227. resolver = PostgresArtifactResolver(engine, store)
  228. schema_fields = [
  229. {"name": "id", "type": "integer", "nullable": False}
  230. ]
  231. same_path = tmp_path / "same.parquet"
  232. conflict_path = tmp_path / "conflict.parquet"
  233. pl.DataFrame({"id": [1]}).write_parquet(same_path)
  234. pl.DataFrame({"id": [2]}).write_parquet(conflict_path)
  235. first = resolver.publish_path(
  236. str(same_path),
  237. binding_id=binding_id,
  238. binding_hash=binding_hash,
  239. correlation_id=correlation_id,
  240. kind="output",
  241. ttl_seconds=300,
  242. schema_fields=schema_fields,
  243. )
  244. repeated = resolver.publish_path(
  245. str(same_path),
  246. binding_id=binding_id,
  247. binding_hash=binding_hash,
  248. correlation_id=correlation_id,
  249. kind="output",
  250. ttl_seconds=300,
  251. schema_fields=schema_fields,
  252. )
  253. assert repeated["artifact_ref"] == first["artifact_ref"]
  254. assert len(store.client.objects) == 1
  255. with pytest.raises(ValueError, match="immutable|digest"):
  256. resolver.publish_path(
  257. str(conflict_path),
  258. binding_id=binding_id,
  259. binding_hash=binding_hash,
  260. correlation_id=correlation_id,
  261. kind="output",
  262. ttl_seconds=300,
  263. schema_fields=schema_fields,
  264. )
  265. assert len(store.client.objects) == 1
  266. with pytest.raises(IntegrityError), engine.begin() as connection:
  267. connection.execute(
  268. text(
  269. """
  270. INSERT INTO public.rule_run_artifacts (
  271. id, correlation_id, binding_id, artifact_ref,
  272. artifact_digest, row_count, schema_hash,
  273. schema_fields, artifact_kind, binding_hash,
  274. handoff_status, expires_at
  275. ) VALUES (
  276. CAST(:id AS uuid),
  277. CAST(:correlation_id AS uuid),
  278. CAST(:binding_id AS uuid), :artifact_ref,
  279. :artifact_digest, 1, :schema_hash,
  280. CAST(:fields AS jsonb), 'output', :binding_hash,
  281. 'pending',
  282. CURRENT_TIMESTAMP + INTERVAL '5 minutes'
  283. )
  284. """
  285. ),
  286. {
  287. "id": new_governance_uid(),
  288. "correlation_id": correlation_id,
  289. "binding_id": binding_id,
  290. "artifact_ref": old_ref,
  291. "artifact_digest": "e" * 64,
  292. "schema_hash": "a" * 64,
  293. "fields": (
  294. '[{"name":"id","type":"integer",'
  295. '"nullable":false}]'
  296. ),
  297. "binding_hash": binding_hash,
  298. },
  299. )
  300. finally:
  301. if engine is not None:
  302. engine.dispose()
  303. with admin.connect() as connection:
  304. connection.execute(
  305. text(
  306. """
  307. SELECT pg_terminate_backend(pid)
  308. FROM pg_stat_activity
  309. WHERE datname = :database_name
  310. AND pid <> pg_backend_pid()
  311. """
  312. ),
  313. {"database_name": database_name},
  314. )
  315. connection.execute(text(f'DROP DATABASE IF EXISTS "{database_name}"'))
  316. admin.dispose()