test_data_factory_postgres_lifecycle.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. from __future__ import annotations
  2. import hashlib
  3. import json
  4. import pytest
  5. from sqlalchemy import create_engine, text
  6. from sqlalchemy.orm import Session
  7. from app.core.common.identifiers import new_governance_uid
  8. from app.core.data_rules.repository import DataRuleRepository
  9. pytestmark = pytest.mark.integration
  10. DATABASE_URL = "postgresql://dataops:dataops-test-password@127.0.0.1:15432/dataops"
  11. def _insert_deployment(
  12. connection,
  13. *,
  14. deployment_id: str,
  15. version_id: str,
  16. dataflow_uid: str,
  17. actor_uid: str,
  18. status: str,
  19. idempotency_key: str,
  20. ) -> None:
  21. hashes = {
  22. "package_hash": "a" * 64,
  23. "binding_hash": "b" * 64,
  24. "schema_snapshot_hash": "c" * 64,
  25. "workflow_spec_hash": "d" * 64,
  26. "schedule_hash": "e" * 64,
  27. "engine_definition_hash": "f" * 64,
  28. }
  29. connection.execute(
  30. text(
  31. """
  32. INSERT INTO public.dataflow_deployments
  33. (id, dataflow_version_id, dataflow_uid, environment, status,
  34. package, package_hash, binding_snapshot, binding_hash,
  35. schema_snapshots, schema_snapshot_hash, physical_plan_hashes,
  36. workflow_spec, workflow_spec_hash, schedule_snapshot,
  37. schedule_hash, engine_namespace, engine_definition_id,
  38. engine_definition_hash, created_by, create_reason,
  39. create_idempotency_key, correlation_id)
  40. VALUES
  41. (CAST(:id AS uuid), CAST(:version_id AS uuid),
  42. CAST(:dataflow_uid AS uuid), 'production', :status,
  43. CAST(:package AS jsonb), :package_hash,
  44. CAST(:binding AS jsonb), :binding_hash,
  45. CAST(:schemas AS jsonb), :schema_snapshot_hash,
  46. CAST(:plans AS jsonb), CAST(:workflow AS jsonb),
  47. :workflow_spec_hash, CAST(:schedule AS jsonb), :schedule_hash,
  48. 'dataops.factory', :definition_id, :engine_definition_hash,
  49. CAST(:actor_uid AS uuid), 'integration acceptance', :key,
  50. CAST(:correlation_id AS uuid))
  51. """
  52. ),
  53. {
  54. "id": deployment_id,
  55. "version_id": version_id,
  56. "dataflow_uid": dataflow_uid,
  57. "status": status,
  58. "package": json.dumps({"schema_version": "1.0"}),
  59. "binding": json.dumps(
  60. {"input": {"binding_id": "in"}, "output": {"binding_id": "out"}}
  61. ),
  62. "schemas": json.dumps({"input": {}, "output": {}}),
  63. "plans": json.dumps(["0" * 64]),
  64. "workflow": json.dumps(
  65. {
  66. "schema_version": "1.0",
  67. "dataflow_uid": dataflow_uid,
  68. "name": "factory postgres acceptance",
  69. "nodes": [],
  70. "edges": [],
  71. "parameters": {},
  72. }
  73. ),
  74. "schedule": json.dumps(
  75. {
  76. "schema_version": "1.0",
  77. "timezone": "Asia/Shanghai",
  78. "triggers": [{"type": "manual"}],
  79. "max_concurrency": 1,
  80. "timeout_seconds": 60,
  81. "retry": {"max_attempts": 1, "delay_seconds": 0},
  82. "conflict_policy": "queue",
  83. "backfill": {"max_days": 1, "max_runs": 1},
  84. }
  85. ),
  86. "definition_id": f"flow-{version_id}",
  87. "actor_uid": actor_uid,
  88. "key": idempotency_key,
  89. "correlation_id": new_governance_uid(),
  90. **hashes,
  91. },
  92. )
  93. def test_postgres_activation_and_rollback_are_atomic_and_idempotent():
  94. engine = create_engine(DATABASE_URL, pool_pre_ping=True)
  95. actor_uid = new_governance_uid()
  96. dataflow_uid = new_governance_uid()
  97. version_ids = [new_governance_uid(), new_governance_uid()]
  98. deployment_ids = [new_governance_uid(), new_governance_uid()]
  99. evidence_id = new_governance_uid()
  100. operation_ids: list[str] = []
  101. try:
  102. with engine.begin() as connection:
  103. connection.execute(
  104. text(
  105. "INSERT INTO public.users "
  106. "(id, username, password_hash) "
  107. "VALUES (CAST(:id AS uuid), :username, 'not-used')"
  108. ),
  109. {"id": actor_uid, "username": f"factory-{actor_uid}"},
  110. )
  111. for index, version_id in enumerate(version_ids, start=1):
  112. connection.execute(
  113. text(
  114. """
  115. INSERT INTO public.dataflow_versions
  116. (id, dataflow_uid, version_no, name, dataflow_spec,
  117. input_schema_hashes, output_schema_hash, status,
  118. created_by)
  119. VALUES
  120. (CAST(:id AS uuid), CAST(:uid AS uuid), :version,
  121. 'factory postgres acceptance', '{}'::jsonb,
  122. '{}'::jsonb, :hash, 'released',
  123. CAST(:actor AS uuid))
  124. """
  125. ),
  126. {
  127. "id": version_id,
  128. "uid": dataflow_uid,
  129. "version": index,
  130. "hash": "9" * 64,
  131. "actor": actor_uid,
  132. },
  133. )
  134. _insert_deployment(
  135. connection,
  136. deployment_id=deployment_ids[0],
  137. version_id=version_ids[0],
  138. dataflow_uid=dataflow_uid,
  139. actor_uid=actor_uid,
  140. status="active",
  141. idempotency_key="factory-pg-create-v1",
  142. )
  143. _insert_deployment(
  144. connection,
  145. deployment_id=deployment_ids[1],
  146. version_id=version_ids[1],
  147. dataflow_uid=dataflow_uid,
  148. actor_uid=actor_uid,
  149. status="canary",
  150. idempotency_key="factory-pg-create-v2",
  151. )
  152. connection.execute(
  153. text(
  154. """
  155. INSERT INTO public.dataflow_canary_evidence
  156. (id, deployment_id, execution_id, status, package_hash,
  157. binding_hash, schema_snapshot_hash, physical_plan_hashes,
  158. workflow_spec_hash, schedule_hash, engine_definition_hash,
  159. verified_by, expires_at)
  160. VALUES
  161. (CAST(:id AS uuid), CAST(:deployment AS uuid), 'exec-pg',
  162. 'passed', :package_hash, :binding_hash, :schema_hash,
  163. CAST(:plans AS jsonb), :workflow_hash, :schedule_hash,
  164. :engine_hash, CAST(:actor AS uuid),
  165. CURRENT_TIMESTAMP + INTERVAL '15 minutes')
  166. """
  167. ),
  168. {
  169. "id": evidence_id,
  170. "deployment": deployment_ids[1],
  171. "actor": actor_uid,
  172. "package_hash": "a" * 64,
  173. "binding_hash": "b" * 64,
  174. "schema_hash": "c" * 64,
  175. "plans": json.dumps(["0" * 64]),
  176. "workflow_hash": "d" * 64,
  177. "schedule_hash": "e" * 64,
  178. "engine_hash": "f" * 64,
  179. },
  180. )
  181. connection.execute(
  182. text(
  183. "UPDATE public.dataflow_deployments "
  184. "SET canary_evidence_id = CAST(:evidence AS uuid) "
  185. "WHERE id = CAST(:id AS uuid)"
  186. ),
  187. {"evidence": evidence_id, "id": deployment_ids[1]},
  188. )
  189. with Session(engine) as session:
  190. repository = DataRuleRepository(session)
  191. claimed, activation = repository.claim_operation(
  192. deployment_ids[1],
  193. "activate",
  194. "factory-pg-activate",
  195. actor_uid,
  196. new_governance_uid(),
  197. "activate acceptance candidate",
  198. {"evidence_id": evidence_id},
  199. )
  200. assert claimed is True
  201. operation_ids.append(activation["id"])
  202. session.commit()
  203. # A different idempotency key/action in the same
  204. # (dataflow_uid, environment) scope observes the current owner and
  205. # cannot create a second in-flight external mutation.
  206. with Session(engine) as competing_session:
  207. competing = DataRuleRepository(competing_session)
  208. second_claimed, blocker = competing.claim_operation(
  209. deployment_ids[1],
  210. "rollback",
  211. "factory-pg-competing-operation",
  212. actor_uid,
  213. new_governance_uid(),
  214. "must be fenced by activation",
  215. {"candidate_deployment_id": deployment_ids[1]},
  216. )
  217. assert second_claimed is False
  218. assert blocker["id"] == activation["id"]
  219. assert blocker["fencing_epoch"] == activation["fencing_epoch"]
  220. competing_session.rollback()
  221. active = repository.activate_atomic(
  222. deployment_ids[1],
  223. 0,
  224. evidence_id,
  225. actor_uid,
  226. activation,
  227. )
  228. session.commit()
  229. assert active["status"] == "active"
  230. assert active["previous_active_deployment_id"] == deployment_ids[0]
  231. assert (
  232. repository.get_deployment(deployment_ids[0])["status"] == "superseded"
  233. )
  234. claimed, rollback = repository.claim_operation(
  235. deployment_ids[1],
  236. "rollback",
  237. "factory-pg-rollback",
  238. actor_uid,
  239. new_governance_uid(),
  240. "rollback acceptance candidate",
  241. {"candidate_deployment_id": deployment_ids[1]},
  242. )
  243. assert claimed is True
  244. operation_ids.append(rollback["id"])
  245. restored = repository.rollback_atomic(
  246. deployment_ids[1],
  247. active["lock_version"],
  248. actor_uid,
  249. rollback,
  250. )
  251. session.commit()
  252. assert restored["rolled_back"]["status"] == "rolled_back"
  253. assert restored["active"]["id"] == deployment_ids[0]
  254. assert restored["active"]["status"] == "active"
  255. replayed, prior = repository.claim_operation(
  256. deployment_ids[1],
  257. "rollback",
  258. "factory-pg-rollback",
  259. actor_uid,
  260. rollback["correlation_id"],
  261. "rollback acceptance candidate",
  262. {"candidate_deployment_id": deployment_ids[1]},
  263. )
  264. assert replayed is False
  265. assert prior["status"] == "completed"
  266. assert prior["result"]["active"]["id"] == deployment_ids[0]
  267. claimed, crashed = repository.claim_operation(
  268. deployment_ids[0],
  269. "deploy_disabled",
  270. "factory-pg-crashed-claim",
  271. actor_uid,
  272. new_governance_uid(),
  273. "simulate process exit after durable claim",
  274. {},
  275. )
  276. assert claimed is True
  277. operation_ids.append(crashed["id"])
  278. first_epoch = crashed["fencing_epoch"]
  279. session.commit()
  280. repository.mark_operation_unknown(
  281. crashed, "acceptance_unknown_before_external_outcome"
  282. )
  283. session.commit()
  284. # Once any operation in the dataflow/environment scope is unknown,
  285. # a different deployment, action and idempotency key must return
  286. # the original blocker and must not create another claim.
  287. blocked, original_unknown = repository.claim_operation(
  288. deployment_ids[1],
  289. "run_canary",
  290. "factory-pg-must-not-bypass-unknown",
  291. actor_uid,
  292. new_governance_uid(),
  293. "must reconcile original unknown first",
  294. {"inputs_hash": "7" * 64},
  295. )
  296. assert blocked is False
  297. assert original_unknown["id"] == crashed["id"]
  298. assert original_unknown["deployment_id"] == deployment_ids[0]
  299. assert original_unknown["action"] == "deploy_disabled"
  300. assert original_unknown["status"] == "unknown"
  301. assert original_unknown["idempotency_key"] == (
  302. "factory-pg-crashed-claim"
  303. )
  304. assert (
  305. session.execute(
  306. text(
  307. "SELECT count(*) FROM "
  308. "public.dataflow_deployment_operations "
  309. "WHERE idempotency_key = "
  310. "'factory-pg-must-not-bypass-unknown'"
  311. )
  312. ).scalar_one()
  313. == 0
  314. )
  315. newer_unknown_id = new_governance_uid()
  316. operation_ids.append(newer_unknown_id)
  317. empty_request = "{}"
  318. session.execute(
  319. text(
  320. "INSERT INTO public.dataflow_deployment_operations "
  321. "(id, deployment_id, action, idempotency_key, status, "
  322. "actor_uid, correlation_id, reason, request, request_hash, "
  323. "error_code, owner_token, lease_expires_at, attempt_epoch, "
  324. "fencing_epoch, started_at) VALUES "
  325. "(CAST(:id AS uuid), CAST(:deployment_id AS uuid), "
  326. "'deploy_disabled', :key, 'unknown', "
  327. "CAST(:actor_uid AS uuid), CAST(:correlation_id AS uuid), "
  328. "'newer legacy unknown', CAST(:request AS jsonb), "
  329. ":request_hash, 'legacy_claim_requires_reconciliation', "
  330. "CAST(:owner_token AS uuid), "
  331. "CURRENT_TIMESTAMP - interval '1 second', 1, 999999, "
  332. "CURRENT_TIMESTAMP + interval '1 second')"
  333. ),
  334. {
  335. "id": newer_unknown_id,
  336. "deployment_id": deployment_ids[1],
  337. "key": "factory-pg-newer-legacy-unknown",
  338. "actor_uid": actor_uid,
  339. "correlation_id": new_governance_uid(),
  340. "request": empty_request,
  341. "request_hash": hashlib.sha256(
  342. empty_request.encode("utf-8")
  343. ).hexdigest(),
  344. "owner_token": new_governance_uid(),
  345. },
  346. )
  347. session.commit()
  348. with pytest.raises(
  349. ValueError,
  350. match="older deployment operation requires reconciliation",
  351. ):
  352. repository.recover_operation(
  353. deployment_ids[1],
  354. "deploy_disabled",
  355. "factory-pg-newer-legacy-unknown",
  356. actor_uid,
  357. )
  358. session.rollback()
  359. session.execute(
  360. text(
  361. "UPDATE public.dataflow_deployment_operations "
  362. "SET lease_expires_at = CURRENT_TIMESTAMP - interval '1 second' "
  363. "WHERE id = CAST(:id AS uuid); "
  364. "UPDATE public.dataflow_deployment_operation_leases "
  365. "SET lease_expires_at = CURRENT_TIMESTAMP - interval '1 second' "
  366. "WHERE operation_id = CAST(:id AS uuid)"
  367. ),
  368. {"id": crashed["id"]},
  369. )
  370. session.commit()
  371. recovered = repository.recover_operation(
  372. deployment_ids[0],
  373. "deploy_disabled",
  374. "factory-pg-crashed-claim",
  375. actor_uid,
  376. )
  377. assert recovered["attempt_epoch"] == 2
  378. assert recovered["fencing_epoch"] > first_epoch
  379. assert repository.owns_operation_lease(recovered) is True
  380. repository.fail_operation(recovered, "acceptance_cleanup")
  381. session.commit()
  382. finally:
  383. with engine.begin() as connection:
  384. connection.execute(
  385. text(
  386. "UPDATE public.dataflow_deployments "
  387. "SET canary_evidence_id = NULL, "
  388. "previous_active_deployment_id = NULL "
  389. "WHERE id = ANY(CAST(:ids AS uuid[]))"
  390. ),
  391. {"ids": deployment_ids},
  392. )
  393. connection.execute(
  394. text(
  395. "DELETE FROM public.dataflow_deployment_transitions "
  396. "WHERE deployment_id = ANY(CAST(:ids AS uuid[]))"
  397. ),
  398. {"ids": deployment_ids},
  399. )
  400. connection.execute(
  401. text(
  402. "DELETE FROM public.dataflow_deployment_operations "
  403. "WHERE deployment_id = ANY(CAST(:ids AS uuid[]))"
  404. ),
  405. {"ids": deployment_ids},
  406. )
  407. connection.execute(
  408. text(
  409. "DELETE FROM public.dataflow_canary_evidence "
  410. "WHERE deployment_id = ANY(CAST(:ids AS uuid[]))"
  411. ),
  412. {"ids": deployment_ids},
  413. )
  414. connection.execute(
  415. text(
  416. "DELETE FROM public.dataflow_deployments "
  417. "WHERE id = ANY(CAST(:ids AS uuid[]))"
  418. ),
  419. {"ids": deployment_ids},
  420. )
  421. connection.execute(
  422. text(
  423. "DELETE FROM public.dataflow_versions "
  424. "WHERE id = ANY(CAST(:ids AS uuid[]))"
  425. ),
  426. {"ids": version_ids},
  427. )
  428. connection.execute(
  429. text("DELETE FROM public.users WHERE id = CAST(:id AS uuid)"),
  430. {"id": actor_uid},
  431. )
  432. engine.dispose()