test_dataflow_draft_reservation_postgres.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. from __future__ import annotations
  2. import os
  3. from concurrent.futures import ThreadPoolExecutor
  4. import pytest
  5. from sqlalchemy import create_engine, text
  6. from sqlalchemy.exc import IntegrityError
  7. from sqlalchemy.orm import Session
  8. from app.core.common.identifiers import new_governance_uid
  9. from app.core.data_rules.repository import DataRuleRepository
  10. def _request(receipt, *, name="Saga Acceptance"):
  11. intent = {
  12. "dataflow_uid": receipt["dataflow_uid"],
  13. "node": {"uid": receipt["dataflow_uid"], "name_zh": name},
  14. "tags": [],
  15. }
  16. return {"payload": {"name_zh": name}, "intent": intent}, intent
  17. def _claim(url, receipt, actor, *, name="Saga Acceptance"):
  18. engine = create_engine(url)
  19. try:
  20. with Session(engine) as session:
  21. try:
  22. create_request, create_intent = _request(receipt, name=name)
  23. value = DataRuleRepository(session).begin_dataflow_create(
  24. receipt,
  25. actor_uid=actor,
  26. create_request=create_request,
  27. create_intent=create_intent,
  28. )
  29. session.commit()
  30. return value
  31. except ValueError as exc:
  32. session.rollback()
  33. return {"status": str(exc)}
  34. finally:
  35. engine.dispose()
  36. def _reconciliation_claim(url):
  37. engine = create_engine(url)
  38. try:
  39. with Session(engine) as session:
  40. rows = DataRuleRepository(
  41. session
  42. ).claim_reconcilable_dataflow_creates(limit=1)
  43. session.commit()
  44. return len(rows)
  45. finally:
  46. engine.dispose()
  47. def test_real_postgres_saga_fk_expiry_concurrent_claim_replay_and_lease_recovery():
  48. url = os.environ.get("DATA_RULE_POSTGRES_ACCEPTANCE_URL")
  49. if not url:
  50. pytest.skip("real PostgreSQL acceptance URL is not configured")
  51. engine = create_engine(url)
  52. created_ids = []
  53. actor = new_governance_uid()
  54. try:
  55. with Session(engine) as session:
  56. session.execute(
  57. text(
  58. "INSERT INTO public.users "
  59. "(id, username, display_name, password_hash, status) "
  60. "VALUES (CAST(:id AS uuid), :username, "
  61. "'Saga Acceptance', 'not-a-login-hash', 'active')"
  62. ),
  63. {"id": actor, "username": f"saga-{actor}"},
  64. )
  65. session.commit()
  66. receipt = DataRuleRepository(session).reserve_dataflow_draft(
  67. actor_uid=actor
  68. )
  69. created_ids.append(receipt["reservation_id"])
  70. session.commit()
  71. closed = {
  72. key: receipt[key]
  73. for key in ("reservation_id", "dataflow_uid", "nonce")
  74. }
  75. with ThreadPoolExecutor(max_workers=2) as pool:
  76. outcomes = list(
  77. pool.map(lambda _index: _claim(url, closed, actor), range(2))
  78. )
  79. claimed = [value for value in outcomes if value["status"] == "claimed"]
  80. assert len(claimed) == 1
  81. assert sorted(value["status"] for value in outcomes) == [
  82. "claimed",
  83. "dataflow_create_in_progress",
  84. ]
  85. result = {"id": 73, "uid": receipt["dataflow_uid"]}
  86. with Session(engine) as session:
  87. repository = DataRuleRepository(session)
  88. assert repository.complete_dataflow_create(
  89. reservation_id=receipt["reservation_id"],
  90. dataflow_uid=receipt["dataflow_uid"],
  91. lease_token=claimed[0]["lease_token"],
  92. request_digest=claimed[0]["request_digest"],
  93. dataflow_node_id=73,
  94. result=result,
  95. ) == result
  96. session.commit()
  97. replay = _claim(url, closed, actor)
  98. assert replay["status"] == "completed"
  99. assert replay["result"] == result
  100. assert _claim(url, closed, actor, name="Tampered")["status"] == (
  101. "dataflow_create_request_conflict"
  102. )
  103. with Session(engine) as session:
  104. expired = DataRuleRepository(session).reserve_dataflow_draft(
  105. actor_uid=actor
  106. )
  107. created_ids.append(expired["reservation_id"])
  108. session.execute(
  109. text(
  110. "UPDATE public.dataflow_draft_reservations "
  111. "SET created_at = CURRENT_TIMESTAMP - INTERVAL '2 seconds', "
  112. "expires_at = CURRENT_TIMESTAMP - INTERVAL '1 second' "
  113. "WHERE id = CAST(:id AS uuid)"
  114. ),
  115. {"id": expired["reservation_id"]},
  116. )
  117. session.commit()
  118. expired_closed = {
  119. key: expired[key]
  120. for key in ("reservation_id", "dataflow_uid", "nonce")
  121. }
  122. assert _claim(url, expired_closed, actor)["status"] == (
  123. "draft_reservation_refresh_required"
  124. )
  125. with Session(engine) as session:
  126. recoverable = DataRuleRepository(session).reserve_dataflow_draft(
  127. actor_uid=actor
  128. )
  129. created_ids.append(recoverable["reservation_id"])
  130. session.commit()
  131. first = DataRuleRepository(session).begin_dataflow_create(
  132. {
  133. key: recoverable[key]
  134. for key in ("reservation_id", "dataflow_uid", "nonce")
  135. },
  136. actor_uid=actor,
  137. create_request=_request(recoverable)[0],
  138. create_intent=_request(recoverable)[1],
  139. lease_seconds=10,
  140. )
  141. session.commit()
  142. session.execute(
  143. text(
  144. "UPDATE public.dataflow_draft_reservations "
  145. "SET lease_expires_at = CURRENT_TIMESTAMP - INTERVAL '1 second' "
  146. "WHERE id = CAST(:id AS uuid)"
  147. ),
  148. {"id": recoverable["reservation_id"]},
  149. )
  150. session.commit()
  151. recovered = _claim(
  152. url,
  153. {
  154. key: recoverable[key]
  155. for key in ("reservation_id", "dataflow_uid", "nonce")
  156. },
  157. actor,
  158. )
  159. assert first["attempt"] == 1
  160. assert recovered["status"] == "claimed"
  161. assert recovered["attempt"] == 2
  162. with Session(engine) as session:
  163. concurrent = DataRuleRepository(
  164. session
  165. ).reserve_dataflow_draft(actor_uid=actor)
  166. created_ids.append(concurrent["reservation_id"])
  167. session.commit()
  168. concurrent_closed = {
  169. key: concurrent[key]
  170. for key in ("reservation_id", "dataflow_uid", "nonce")
  171. }
  172. request_value, intent_value = _request(concurrent)
  173. DataRuleRepository(session).begin_dataflow_create(
  174. concurrent_closed,
  175. actor_uid=actor,
  176. create_request=request_value,
  177. create_intent=intent_value,
  178. lease_seconds=10,
  179. )
  180. session.commit()
  181. session.execute(
  182. text(
  183. "UPDATE public.dataflow_draft_reservations "
  184. "SET lease_expires_at = CURRENT_TIMESTAMP - INTERVAL '1 second' "
  185. "WHERE id = CAST(:id AS uuid)"
  186. ),
  187. {"id": concurrent["reservation_id"]},
  188. )
  189. session.commit()
  190. with ThreadPoolExecutor(max_workers=2) as pool:
  191. reconciliation_claims = list(
  192. pool.map(lambda _index: _reconciliation_claim(url), range(2))
  193. )
  194. assert sorted(reconciliation_claims) == [0, 1]
  195. with Session(engine) as session:
  196. with pytest.raises(IntegrityError):
  197. DataRuleRepository(session).reserve_dataflow_draft(
  198. actor_uid=new_governance_uid()
  199. )
  200. session.commit()
  201. session.rollback()
  202. finally:
  203. with Session(engine) as session:
  204. session.execute(
  205. text(
  206. "DELETE FROM public.dataflow_draft_reservations "
  207. "WHERE id = ANY(CAST(:ids AS uuid[]))"
  208. ),
  209. {"ids": created_ids},
  210. )
  211. session.execute(
  212. text(
  213. "DELETE FROM public.users WHERE id = CAST(:id AS uuid)"
  214. ),
  215. {"id": actor},
  216. )
  217. session.commit()
  218. engine.dispose()