test_rule_artifact_migration_upgrade.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  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.engine import make_url
  12. from sqlalchemy.exc import IntegrityError
  13. from app.core.common.identifiers import new_governance_uid
  14. from tests.runner.test_artifacts import FakeMinio, _store
  15. ROOT = Path(__file__).resolve().parents[2]
  16. COMPOSE = ROOT / "deploy" / "docker" / "docker-compose.yml"
  17. def _compose_value(pattern: str) -> str:
  18. source = re.sub(
  19. r"\$\{[A-Za-z_][A-Za-z0-9_]*:-([^}]+)\}",
  20. r"\1",
  21. COMPOSE.read_text(encoding="utf-8"),
  22. )
  23. match = re.search(
  24. pattern,
  25. source,
  26. flags=re.DOTALL,
  27. )
  28. assert match is not None
  29. return match.group(1)
  30. def _upgrade(database_url: str, revision: str) -> None:
  31. previous = os.environ.get("DATABASE_URL")
  32. root_logger = logging.getLogger()
  33. root_handlers = list(root_logger.handlers)
  34. root_level = root_logger.level
  35. logger_disabled = {
  36. name: logger.disabled
  37. for name, logger in logging.Logger.manager.loggerDict.items()
  38. if isinstance(logger, logging.Logger)
  39. }
  40. os.environ["DATABASE_URL"] = database_url
  41. try:
  42. command.upgrade(Config(str(ROOT / "alembic.ini")), revision)
  43. finally:
  44. root_logger.handlers[:] = root_handlers
  45. root_logger.setLevel(root_level)
  46. for name, disabled in logger_disabled.items():
  47. logging.getLogger(name).disabled = disabled
  48. if previous is None:
  49. os.environ.pop("DATABASE_URL", None)
  50. else:
  51. os.environ["DATABASE_URL"] = previous
  52. def test_old_140_upgrades_to_durable_handoff_and_enforces_cas(
  53. tmp_path, wp06_postgres_identities
  54. ):
  55. from app.runner.artifacts import PostgresArtifactResolver
  56. platform_user = _compose_value(
  57. r"\n postgres:.*?POSTGRES_USER:\s*([^\s]+)"
  58. )
  59. platform_password = _compose_value(
  60. r"\n postgres:.*?POSTGRES_PASSWORD:\s*([^\s]+)"
  61. )
  62. platform_port = _compose_value(r'"(15432):5432"')
  63. admin_url = (
  64. f"postgresql+psycopg2://{platform_user}:{platform_password}"
  65. f"@127.0.0.1:{platform_port}/postgres"
  66. )
  67. database_name = f"task5_migration_{new_governance_uid().replace('-', '')}"
  68. database_url = (
  69. f"postgresql+psycopg2://{platform_user}:{platform_password}"
  70. f"@127.0.0.1:{platform_port}/{database_name}"
  71. )
  72. migration_url = make_url(wp06_postgres_identities.migration_url).set(
  73. database=database_name
  74. ).render_as_string(hide_password=False)
  75. admin = create_engine(admin_url, isolation_level="AUTOCOMMIT")
  76. engine = None
  77. try:
  78. with admin.connect() as connection:
  79. connection.execute(text(f'CREATE DATABASE "{database_name}"'))
  80. _upgrade(database_url, "20260723_140")
  81. engine = create_engine(database_url, pool_pre_ping=True)
  82. dataflow_version_id = new_governance_uid()
  83. deployment_id = new_governance_uid()
  84. schema_id = new_governance_uid()
  85. binding_id = new_governance_uid()
  86. binding_hash = "b" * 64
  87. correlation_id = new_governance_uid()
  88. old_artifact_id = new_governance_uid()
  89. old_ref = (
  90. f"minio://dataops-rules/rules/{correlation_id}/"
  91. f"{new_governance_uid()}.parquet"
  92. )
  93. with engine.begin() as connection:
  94. columns_before = {
  95. row[0]
  96. for row in connection.execute(
  97. text(
  98. """
  99. SELECT column_name
  100. FROM information_schema.columns
  101. WHERE table_schema = 'public'
  102. AND table_name = 'rule_run_artifacts'
  103. """
  104. )
  105. )
  106. }
  107. assert "handoff_status" not in columns_before
  108. connection.execute(
  109. text(
  110. """
  111. INSERT INTO public.dataflow_versions (
  112. id, dataflow_uid, version_no, name, dataflow_spec,
  113. input_schema_hashes, output_schema_hash, status
  114. ) VALUES (
  115. CAST(:id AS uuid), CAST(:uid AS uuid), 1, 'migration',
  116. '{}'::jsonb, '[]'::jsonb, :schema_hash, 'released'
  117. )
  118. """
  119. ),
  120. {
  121. "id": dataflow_version_id,
  122. "uid": new_governance_uid(),
  123. "schema_hash": "a" * 64,
  124. },
  125. )
  126. connection.execute(
  127. text(
  128. """
  129. INSERT INTO public.dataflow_deployments (
  130. id, dataflow_version_id, environment,
  131. deployment_config, status
  132. ) VALUES (
  133. CAST(:id AS uuid), CAST(:version_id AS uuid), 'test',
  134. '{}'::jsonb, 'active'
  135. )
  136. """
  137. ),
  138. {"id": deployment_id, "version_id": dataflow_version_id},
  139. )
  140. connection.execute(
  141. text(
  142. """
  143. INSERT INTO public.data_schema_snapshots (
  144. id, schema_ref, schema_hash, fields, source_revision
  145. ) VALUES (
  146. CAST(:id AS uuid), 'migration:id', :schema_hash,
  147. CAST(:fields AS jsonb), 'old-140'
  148. )
  149. """
  150. ),
  151. {
  152. "id": schema_id,
  153. "schema_hash": "a" * 64,
  154. "fields": (
  155. '[{"name":"id","type":"integer",'
  156. '"nullable":false}]'
  157. ),
  158. },
  159. )
  160. connection.execute(
  161. text(
  162. """
  163. INSERT INTO public.dataflow_dataset_bindings (
  164. id, dataflow_deployment_id, logical_ref,
  165. object_kind, object_ref, schema_snapshot_id, dialect,
  166. access_mode, write_mode, binding_hash
  167. ) VALUES (
  168. CAST(:id AS uuid), CAST(:deployment_id AS uuid),
  169. 'output', 'parquet_artifact', 'migration-output',
  170. CAST(:schema_id AS uuid), 'parquet', 'write',
  171. 'append', :binding_hash
  172. )
  173. """
  174. ),
  175. {
  176. "id": binding_id,
  177. "deployment_id": deployment_id,
  178. "schema_id": schema_id,
  179. "binding_hash": binding_hash,
  180. },
  181. )
  182. connection.execute(
  183. text(
  184. """
  185. INSERT INTO public.rule_run_artifacts (
  186. id, correlation_id, binding_id, artifact_ref,
  187. artifact_digest, row_count, schema_hash, schema_fields,
  188. artifact_kind, expires_at
  189. ) VALUES (
  190. CAST(:id AS uuid), CAST(:correlation_id AS uuid),
  191. CAST(:binding_id AS uuid), :artifact_ref,
  192. :digest, 1, :schema_hash, CAST(:fields AS jsonb),
  193. 'output', CURRENT_TIMESTAMP + INTERVAL '1 hour'
  194. )
  195. """
  196. ),
  197. {
  198. "id": old_artifact_id,
  199. "correlation_id": correlation_id,
  200. "binding_id": binding_id,
  201. "artifact_ref": old_ref,
  202. "digest": "d" * 64,
  203. "schema_hash": "a" * 64,
  204. "fields": (
  205. '[{"name":"id","type":"integer",'
  206. '"nullable":false}]'
  207. ),
  208. },
  209. )
  210. engine.dispose()
  211. engine = None
  212. _upgrade(migration_url, "head")
  213. engine = create_engine(database_url, pool_pre_ping=True)
  214. with engine.begin() as connection:
  215. migrated = connection.execute(
  216. text(
  217. """
  218. SELECT binding_hash, handoff_status, ready_at
  219. FROM public.rule_run_artifacts
  220. WHERE id = CAST(:id AS uuid)
  221. """
  222. ),
  223. {"id": old_artifact_id},
  224. ).mappings().one()
  225. assert migrated["binding_hash"] == binding_hash
  226. assert migrated["handoff_status"] == "ready"
  227. assert migrated["ready_at"] is not None
  228. connection.execute(
  229. text(
  230. """
  231. DELETE FROM public.rule_run_artifacts
  232. WHERE id = CAST(:id AS uuid)
  233. """
  234. ),
  235. {"id": old_artifact_id},
  236. )
  237. store = _store(FakeMinio())
  238. resolver = PostgresArtifactResolver(engine, store)
  239. schema_fields = [
  240. {"name": "id", "type": "integer", "nullable": False}
  241. ]
  242. same_path = tmp_path / "same.parquet"
  243. conflict_path = tmp_path / "conflict.parquet"
  244. pl.DataFrame({"id": [1]}).write_parquet(same_path)
  245. pl.DataFrame({"id": [2]}).write_parquet(conflict_path)
  246. first = resolver.publish_path(
  247. str(same_path),
  248. binding_id=binding_id,
  249. binding_hash=binding_hash,
  250. correlation_id=correlation_id,
  251. kind="output",
  252. ttl_seconds=300,
  253. schema_fields=schema_fields,
  254. )
  255. repeated = resolver.publish_path(
  256. str(same_path),
  257. binding_id=binding_id,
  258. binding_hash=binding_hash,
  259. correlation_id=correlation_id,
  260. kind="output",
  261. ttl_seconds=300,
  262. schema_fields=schema_fields,
  263. )
  264. assert repeated["artifact_ref"] == first["artifact_ref"]
  265. assert len(store.client.objects) == 1
  266. with pytest.raises(ValueError, match="immutable|digest"):
  267. resolver.publish_path(
  268. str(conflict_path),
  269. binding_id=binding_id,
  270. binding_hash=binding_hash,
  271. correlation_id=correlation_id,
  272. kind="output",
  273. ttl_seconds=300,
  274. schema_fields=schema_fields,
  275. )
  276. assert len(store.client.objects) == 1
  277. with pytest.raises(IntegrityError), engine.begin() as connection:
  278. connection.execute(
  279. text(
  280. """
  281. INSERT INTO public.rule_run_artifacts (
  282. id, correlation_id, binding_id, artifact_ref,
  283. artifact_digest, row_count, schema_hash,
  284. schema_fields, artifact_kind, binding_hash,
  285. handoff_status, expires_at
  286. ) VALUES (
  287. CAST(:id AS uuid),
  288. CAST(:correlation_id AS uuid),
  289. CAST(:binding_id AS uuid), :artifact_ref,
  290. :artifact_digest, 1, :schema_hash,
  291. CAST(:fields AS jsonb), 'output', :binding_hash,
  292. 'pending',
  293. CURRENT_TIMESTAMP + INTERVAL '5 minutes'
  294. )
  295. """
  296. ),
  297. {
  298. "id": new_governance_uid(),
  299. "correlation_id": correlation_id,
  300. "binding_id": binding_id,
  301. "artifact_ref": old_ref,
  302. "artifact_digest": "e" * 64,
  303. "schema_hash": "a" * 64,
  304. "fields": (
  305. '[{"name":"id","type":"integer",'
  306. '"nullable":false}]'
  307. ),
  308. "binding_hash": binding_hash,
  309. },
  310. )
  311. finally:
  312. if engine is not None:
  313. engine.dispose()
  314. with admin.connect() as connection:
  315. connection.execute(
  316. text(
  317. """
  318. SELECT pg_terminate_backend(pid)
  319. FROM pg_stat_activity
  320. WHERE datname = :database_name
  321. AND pid <> pg_backend_pid()
  322. """
  323. ),
  324. {"database_name": database_name},
  325. )
  326. connection.execute(text(f'DROP DATABASE IF EXISTS "{database_name}"'))
  327. admin.dispose()
  328. def test_old_180_cleanup_claims_become_expired_and_cas_takeover_ready():
  329. platform_user = _compose_value(
  330. r"\n postgres:.*?POSTGRES_USER:\s*([^\s]+)"
  331. )
  332. platform_password = _compose_value(
  333. r"\n postgres:.*?POSTGRES_PASSWORD:\s*([^\s]+)"
  334. )
  335. platform_port = _compose_value(r'"(15432):5432"')
  336. admin_url = (
  337. f"postgresql+psycopg2://{platform_user}:{platform_password}"
  338. f"@127.0.0.1:{platform_port}/postgres"
  339. )
  340. database_name = f"task6_claim_{new_governance_uid().replace('-', '')}"
  341. database_url = (
  342. f"postgresql+psycopg2://{platform_user}:{platform_password}"
  343. f"@127.0.0.1:{platform_port}/{database_name}"
  344. )
  345. admin = create_engine(admin_url, isolation_level="AUTOCOMMIT")
  346. engine = None
  347. try:
  348. with admin.connect() as connection:
  349. connection.execute(text(f'CREATE DATABASE "{database_name}"'))
  350. _upgrade(database_url, "20260723_180")
  351. engine = create_engine(database_url, pool_pre_ping=True)
  352. rule_run_id = new_governance_uid()
  353. sample_id = new_governance_uid()
  354. receipt_id = new_governance_uid()
  355. old_sample_claim = new_governance_uid()
  356. old_receipt_claim = new_governance_uid()
  357. with engine.begin() as connection:
  358. expiry_columns = connection.execute(
  359. text(
  360. """
  361. SELECT table_name
  362. FROM information_schema.columns
  363. WHERE table_schema = 'public'
  364. AND table_name IN (
  365. 'rule_violation_samples',
  366. 'rule_sql_staging_receipts'
  367. )
  368. AND column_name = 'cleanup_claim_expires_at'
  369. """
  370. )
  371. ).all()
  372. assert expiry_columns == []
  373. connection.execute(text("SET session_replication_role = replica"))
  374. connection.execute(
  375. text(
  376. """
  377. INSERT INTO public.rule_runs (
  378. id, deployment_id, component_binding_id,
  379. rule_version_id, plan_hash, status, correlation_id
  380. ) VALUES (
  381. CAST(:id AS uuid), CAST(:deployment_id AS uuid),
  382. CAST(:component_id AS uuid),
  383. CAST(:rule_version_id AS uuid),
  384. :plan_hash, 'failed', CAST(:correlation_id AS uuid)
  385. )
  386. """
  387. ),
  388. {
  389. "id": rule_run_id,
  390. "deployment_id": new_governance_uid(),
  391. "component_id": new_governance_uid(),
  392. "rule_version_id": new_governance_uid(),
  393. "plan_hash": "a" * 64,
  394. "correlation_id": new_governance_uid(),
  395. },
  396. )
  397. connection.execute(
  398. text(
  399. """
  400. INSERT INTO public.rule_violation_samples (
  401. id, rule_run_id, artifact_ref, sample_count,
  402. redaction_policy, expires_at, cleanup_claim
  403. ) VALUES (
  404. CAST(:id AS uuid), CAST(:rule_run_id AS uuid),
  405. :artifact_ref, 1, 'all-fields',
  406. CURRENT_TIMESTAMP - INTERVAL '1 hour',
  407. CAST(:cleanup_claim AS uuid)
  408. )
  409. """
  410. ),
  411. {
  412. "id": sample_id,
  413. "rule_run_id": rule_run_id,
  414. "artifact_ref": (
  415. f"minio://legacy/{new_governance_uid()}.parquet"
  416. ),
  417. "cleanup_claim": old_sample_claim,
  418. },
  419. )
  420. connection.execute(
  421. text(
  422. """
  423. INSERT INTO public.rule_sql_staging_receipts (
  424. id, producer_rule_run_id, deployment_id,
  425. correlation_id, output_binding_id,
  426. output_binding_hash, relation_ref, relation_digest,
  427. commit_outcome, status, expires_at, cleanup_claim
  428. ) VALUES (
  429. CAST(:id AS uuid), CAST(:run_id AS uuid),
  430. CAST(:deployment_id AS uuid),
  431. CAST(:correlation_id AS uuid),
  432. CAST(:binding_id AS uuid), :binding_hash,
  433. :relation_ref, :relation_digest,
  434. 'committed', 'expired',
  435. CURRENT_TIMESTAMP - INTERVAL '1 hour',
  436. CAST(:cleanup_claim AS uuid)
  437. )
  438. """
  439. ),
  440. {
  441. "id": receipt_id,
  442. "run_id": rule_run_id,
  443. "deployment_id": new_governance_uid(),
  444. "correlation_id": new_governance_uid(),
  445. "binding_id": new_governance_uid(),
  446. "binding_hash": "b" * 64,
  447. "relation_ref": "legacy.receipt",
  448. "relation_digest": "c" * 64,
  449. "cleanup_claim": old_receipt_claim,
  450. },
  451. )
  452. connection.execute(text("SET session_replication_role = origin"))
  453. engine.dispose()
  454. engine = None
  455. _upgrade(database_url, "20260723_190")
  456. engine = create_engine(database_url, pool_pre_ping=True)
  457. new_sample_claim = new_governance_uid()
  458. new_receipt_claim = new_governance_uid()
  459. with engine.begin() as connection:
  460. migrated = connection.execute(
  461. text(
  462. """
  463. SELECT
  464. (
  465. SELECT cleanup_claim_expires_at
  466. FROM public.rule_violation_samples
  467. WHERE id = CAST(:sample_id AS uuid)
  468. ) AS sample_claim_expires_at,
  469. (
  470. SELECT cleanup_claim_expires_at
  471. FROM public.rule_sql_staging_receipts
  472. WHERE id = CAST(:receipt_id AS uuid)
  473. ) AS receipt_claim_expires_at,
  474. CURRENT_TIMESTAMP AS observed_at
  475. """
  476. ),
  477. {"sample_id": sample_id, "receipt_id": receipt_id},
  478. ).mappings().one()
  479. assert migrated["sample_claim_expires_at"] is not None
  480. assert migrated["receipt_claim_expires_at"] is not None
  481. assert migrated["sample_claim_expires_at"] <= migrated["observed_at"]
  482. assert migrated["receipt_claim_expires_at"] <= migrated["observed_at"]
  483. sample_takeover = connection.execute(
  484. text(
  485. """
  486. UPDATE public.rule_violation_samples
  487. SET cleanup_claim = CAST(:new_claim AS uuid),
  488. cleanup_claim_expires_at =
  489. CURRENT_TIMESTAMP + INTERVAL '5 minutes'
  490. WHERE id = CAST(:id AS uuid)
  491. AND cleanup_claim = CAST(:old_claim AS uuid)
  492. AND cleanup_claim_expires_at <= CURRENT_TIMESTAMP
  493. RETURNING cleanup_claim
  494. """
  495. ),
  496. {
  497. "id": sample_id,
  498. "old_claim": old_sample_claim,
  499. "new_claim": new_sample_claim,
  500. },
  501. ).scalar_one()
  502. receipt_takeover = connection.execute(
  503. text(
  504. """
  505. UPDATE public.rule_sql_staging_receipts
  506. SET cleanup_claim = CAST(:new_claim AS uuid),
  507. cleanup_claim_expires_at =
  508. CURRENT_TIMESTAMP + INTERVAL '5 minutes'
  509. WHERE id = CAST(:id AS uuid)
  510. AND cleanup_claim = CAST(:old_claim AS uuid)
  511. AND cleanup_claim_expires_at <= CURRENT_TIMESTAMP
  512. RETURNING cleanup_claim
  513. """
  514. ),
  515. {
  516. "id": receipt_id,
  517. "old_claim": old_receipt_claim,
  518. "new_claim": new_receipt_claim,
  519. },
  520. ).scalar_one()
  521. assert str(sample_takeover) == new_sample_claim
  522. assert str(receipt_takeover) == new_receipt_claim
  523. finally:
  524. if engine is not None:
  525. engine.dispose()
  526. with admin.connect() as connection:
  527. connection.execute(
  528. text(
  529. """
  530. SELECT pg_terminate_backend(pid)
  531. FROM pg_stat_activity
  532. WHERE datname = :database_name
  533. AND pid <> pg_backend_pid()
  534. """
  535. ),
  536. {"database_name": database_name},
  537. )
  538. connection.execute(text(f'DROP DATABASE IF EXISTS "{database_name}"'))
  539. admin.dispose()