test_rule_artifact_migration_upgrade.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  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()
  317. def test_old_180_cleanup_claims_become_expired_and_cas_takeover_ready():
  318. platform_user = _compose_value(
  319. r"\n postgres:.*?POSTGRES_USER:\s*([^\s]+)"
  320. )
  321. platform_password = _compose_value(
  322. r"\n postgres:.*?POSTGRES_PASSWORD:\s*([^\s]+)"
  323. )
  324. platform_port = _compose_value(r'"(15432):5432"')
  325. admin_url = (
  326. f"postgresql+psycopg2://{platform_user}:{platform_password}"
  327. f"@127.0.0.1:{platform_port}/postgres"
  328. )
  329. database_name = f"task6_claim_{new_governance_uid().replace('-', '')}"
  330. database_url = (
  331. f"postgresql+psycopg2://{platform_user}:{platform_password}"
  332. f"@127.0.0.1:{platform_port}/{database_name}"
  333. )
  334. admin = create_engine(admin_url, isolation_level="AUTOCOMMIT")
  335. engine = None
  336. try:
  337. with admin.connect() as connection:
  338. connection.execute(text(f'CREATE DATABASE "{database_name}"'))
  339. _upgrade(database_url, "20260723_180")
  340. engine = create_engine(database_url, pool_pre_ping=True)
  341. rule_run_id = new_governance_uid()
  342. sample_id = new_governance_uid()
  343. receipt_id = new_governance_uid()
  344. old_sample_claim = new_governance_uid()
  345. old_receipt_claim = new_governance_uid()
  346. with engine.begin() as connection:
  347. expiry_columns = connection.execute(
  348. text(
  349. """
  350. SELECT table_name
  351. FROM information_schema.columns
  352. WHERE table_schema = 'public'
  353. AND table_name IN (
  354. 'rule_violation_samples',
  355. 'rule_sql_staging_receipts'
  356. )
  357. AND column_name = 'cleanup_claim_expires_at'
  358. """
  359. )
  360. ).all()
  361. assert expiry_columns == []
  362. connection.execute(text("SET session_replication_role = replica"))
  363. connection.execute(
  364. text(
  365. """
  366. INSERT INTO public.rule_runs (
  367. id, deployment_id, component_binding_id,
  368. rule_version_id, plan_hash, status, correlation_id
  369. ) VALUES (
  370. CAST(:id AS uuid), CAST(:deployment_id AS uuid),
  371. CAST(:component_id AS uuid),
  372. CAST(:rule_version_id AS uuid),
  373. :plan_hash, 'failed', CAST(:correlation_id AS uuid)
  374. )
  375. """
  376. ),
  377. {
  378. "id": rule_run_id,
  379. "deployment_id": new_governance_uid(),
  380. "component_id": new_governance_uid(),
  381. "rule_version_id": new_governance_uid(),
  382. "plan_hash": "a" * 64,
  383. "correlation_id": new_governance_uid(),
  384. },
  385. )
  386. connection.execute(
  387. text(
  388. """
  389. INSERT INTO public.rule_violation_samples (
  390. id, rule_run_id, artifact_ref, sample_count,
  391. redaction_policy, expires_at, cleanup_claim
  392. ) VALUES (
  393. CAST(:id AS uuid), CAST(:rule_run_id AS uuid),
  394. :artifact_ref, 1, 'all-fields',
  395. CURRENT_TIMESTAMP - INTERVAL '1 hour',
  396. CAST(:cleanup_claim AS uuid)
  397. )
  398. """
  399. ),
  400. {
  401. "id": sample_id,
  402. "rule_run_id": rule_run_id,
  403. "artifact_ref": (
  404. f"minio://legacy/{new_governance_uid()}.parquet"
  405. ),
  406. "cleanup_claim": old_sample_claim,
  407. },
  408. )
  409. connection.execute(
  410. text(
  411. """
  412. INSERT INTO public.rule_sql_staging_receipts (
  413. id, producer_rule_run_id, deployment_id,
  414. correlation_id, output_binding_id,
  415. output_binding_hash, relation_ref, relation_digest,
  416. commit_outcome, status, expires_at, cleanup_claim
  417. ) VALUES (
  418. CAST(:id AS uuid), CAST(:run_id AS uuid),
  419. CAST(:deployment_id AS uuid),
  420. CAST(:correlation_id AS uuid),
  421. CAST(:binding_id AS uuid), :binding_hash,
  422. :relation_ref, :relation_digest,
  423. 'committed', 'expired',
  424. CURRENT_TIMESTAMP - INTERVAL '1 hour',
  425. CAST(:cleanup_claim AS uuid)
  426. )
  427. """
  428. ),
  429. {
  430. "id": receipt_id,
  431. "run_id": rule_run_id,
  432. "deployment_id": new_governance_uid(),
  433. "correlation_id": new_governance_uid(),
  434. "binding_id": new_governance_uid(),
  435. "binding_hash": "b" * 64,
  436. "relation_ref": "legacy.receipt",
  437. "relation_digest": "c" * 64,
  438. "cleanup_claim": old_receipt_claim,
  439. },
  440. )
  441. connection.execute(text("SET session_replication_role = origin"))
  442. engine.dispose()
  443. engine = None
  444. _upgrade(database_url, "20260723_190")
  445. engine = create_engine(database_url, pool_pre_ping=True)
  446. new_sample_claim = new_governance_uid()
  447. new_receipt_claim = new_governance_uid()
  448. with engine.begin() as connection:
  449. migrated = connection.execute(
  450. text(
  451. """
  452. SELECT
  453. (
  454. SELECT cleanup_claim_expires_at
  455. FROM public.rule_violation_samples
  456. WHERE id = CAST(:sample_id AS uuid)
  457. ) AS sample_claim_expires_at,
  458. (
  459. SELECT cleanup_claim_expires_at
  460. FROM public.rule_sql_staging_receipts
  461. WHERE id = CAST(:receipt_id AS uuid)
  462. ) AS receipt_claim_expires_at,
  463. CURRENT_TIMESTAMP AS observed_at
  464. """
  465. ),
  466. {"sample_id": sample_id, "receipt_id": receipt_id},
  467. ).mappings().one()
  468. assert migrated["sample_claim_expires_at"] is not None
  469. assert migrated["receipt_claim_expires_at"] is not None
  470. assert migrated["sample_claim_expires_at"] <= migrated["observed_at"]
  471. assert migrated["receipt_claim_expires_at"] <= migrated["observed_at"]
  472. sample_takeover = connection.execute(
  473. text(
  474. """
  475. UPDATE public.rule_violation_samples
  476. SET cleanup_claim = CAST(:new_claim AS uuid),
  477. cleanup_claim_expires_at =
  478. CURRENT_TIMESTAMP + INTERVAL '5 minutes'
  479. WHERE id = CAST(:id AS uuid)
  480. AND cleanup_claim = CAST(:old_claim AS uuid)
  481. AND cleanup_claim_expires_at <= CURRENT_TIMESTAMP
  482. RETURNING cleanup_claim
  483. """
  484. ),
  485. {
  486. "id": sample_id,
  487. "old_claim": old_sample_claim,
  488. "new_claim": new_sample_claim,
  489. },
  490. ).scalar_one()
  491. receipt_takeover = connection.execute(
  492. text(
  493. """
  494. UPDATE public.rule_sql_staging_receipts
  495. SET cleanup_claim = CAST(:new_claim AS uuid),
  496. cleanup_claim_expires_at =
  497. CURRENT_TIMESTAMP + INTERVAL '5 minutes'
  498. WHERE id = CAST(:id AS uuid)
  499. AND cleanup_claim = CAST(:old_claim AS uuid)
  500. AND cleanup_claim_expires_at <= CURRENT_TIMESTAMP
  501. RETURNING cleanup_claim
  502. """
  503. ),
  504. {
  505. "id": receipt_id,
  506. "old_claim": old_receipt_claim,
  507. "new_claim": new_receipt_claim,
  508. },
  509. ).scalar_one()
  510. assert str(sample_takeover) == new_sample_claim
  511. assert str(receipt_takeover) == new_receipt_claim
  512. finally:
  513. if engine is not None:
  514. engine.dispose()
  515. with admin.connect() as connection:
  516. connection.execute(
  517. text(
  518. """
  519. SELECT pg_terminate_backend(pid)
  520. FROM pg_stat_activity
  521. WHERE datname = :database_name
  522. AND pid <> pg_backend_pid()
  523. """
  524. ),
  525. {"database_name": database_name},
  526. )
  527. connection.execute(text(f'DROP DATABASE IF EXISTS "{database_name}"'))
  528. admin.dispose()