test_mcp_gateway_persistence.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  1. import os
  2. import pytest
  3. from sqlalchemy import create_engine, text
  4. from app.core.common.identifiers import new_governance_uid
  5. from app.core.mcp.persistence import (
  6. PostgresAuditSink,
  7. PostgresContextRepository,
  8. PostgresSchedulingPlanStore,
  9. )
  10. pytestmark = pytest.mark.skipif(
  11. os.getenv("DATAOPS_MCP_PERSISTENCE_INTEGRATION") != "1",
  12. reason="set DATAOPS_MCP_PERSISTENCE_INTEGRATION=1",
  13. )
  14. def workflow_spec(dataflow_uid):
  15. return {
  16. "schema_version": "1.0",
  17. "dataflow_uid": dataflow_uid,
  18. "name": "V53 persistence acceptance",
  19. "nodes": [
  20. {
  21. "id": "read_orders",
  22. "type": "sql.query",
  23. "data_source_uid": new_governance_uid(),
  24. "purpose": "read",
  25. "config": {"statement": "SELECT 1", "parameters": {}},
  26. }
  27. ],
  28. "edges": [],
  29. "parameters": {},
  30. }
  31. def schedule_plan(*, triggers=None):
  32. return {
  33. "schema_version": "1.0",
  34. "timezone": "Asia/Shanghai",
  35. "triggers": triggers or [{"type": "manual"}],
  36. "max_concurrency": 1,
  37. "conflict_policy": "skip",
  38. "timeout_seconds": 300,
  39. "retry": {"max_attempts": 1, "delay_seconds": 0},
  40. "backfill": {"max_days": 2, "max_runs": 5},
  41. }
  42. def test_postgres_store_persists_candidate_canary_idempotency_and_audit():
  43. database_url = os.environ["DATAOPS_MCP_TEST_DATABASE_URL"]
  44. engine = create_engine(database_url, pool_pre_ping=True)
  45. store = PostgresSchedulingPlanStore(engine)
  46. audit = PostgresAuditSink(engine)
  47. dataflow_uid = new_governance_uid()
  48. correlation_id = new_governance_uid()
  49. candidate_id = None
  50. try:
  51. candidate = store.create_candidate(
  52. {
  53. "dataflow_uid": dataflow_uid,
  54. "business_domain": "sales",
  55. "environment": "test",
  56. "workflow_spec": workflow_spec(dataflow_uid),
  57. "schedule_plan": schedule_plan(),
  58. "workflow_hash": "a" * 64,
  59. "status": "candidate",
  60. "created_by": "agent-scheduler",
  61. "correlation_id": correlation_id,
  62. }
  63. )
  64. candidate_id = candidate["candidate_id"]
  65. assert candidate["version_no"] >= 1
  66. assert store.get_candidate(candidate_id)["business_domain"] == "sales"
  67. deployment = store.record_deployment(
  68. candidate_id,
  69. {
  70. "candidate_id": candidate_id,
  71. "namespace": "dataops.test",
  72. "flow_id": f"v53_{candidate_id.replace('-', '')}",
  73. "definition_hash": "b" * 64,
  74. "engine_revision": "revision-1",
  75. "status": "deployed_disabled",
  76. },
  77. )
  78. assert store.get_deployment(candidate_id) == deployment
  79. evidence = store.record_canary_execution(
  80. candidate_id,
  81. {
  82. "candidate_id": candidate_id,
  83. "execution_id": f"canary-{candidate_id}",
  84. "status": "started",
  85. "sample_runs": 0,
  86. },
  87. )
  88. assert evidence["status"] == "started"
  89. assert store.get_canary_evidence(candidate_id)["status"] == "started"
  90. verified = store.record_canary_verification(
  91. candidate_id,
  92. {
  93. **evidence,
  94. "status": "passed",
  95. "sample_runs": 1,
  96. "verified_by": "dataops-canary-verifier",
  97. "verification_summary": {
  98. "engine_state": "SUCCESS",
  99. "equivalent": True,
  100. "metrics": {"row_count_delta": 0},
  101. },
  102. },
  103. )
  104. assert verified["status"] == "passed"
  105. assert store.get_canary_evidence(candidate_id)["verified_by"] == (
  106. "dataops-canary-verifier"
  107. )
  108. promotion_key = f"promotion:v53:{candidate_id}"
  109. first = store.claim_promotion(
  110. promotion_key,
  111. candidate_id,
  112. actor_subject="agent-scheduler",
  113. correlation_id=correlation_id,
  114. )
  115. second = store.claim_promotion(
  116. promotion_key,
  117. candidate_id,
  118. actor_subject="agent-scheduler",
  119. correlation_id=correlation_id,
  120. )
  121. assert first[0] is True
  122. assert second[0] is False
  123. audit.record(
  124. {
  125. "subject": "agent-scheduler",
  126. "roles": ["scheduler"],
  127. "business_domain": "sales",
  128. "environment": "test",
  129. "correlation_id": correlation_id,
  130. "action": "deploy_disabled_version",
  131. "decision": "allowed",
  132. "detail": {"candidate_id": candidate_id},
  133. }
  134. )
  135. with engine.connect() as connection:
  136. row = connection.execute(
  137. text(
  138. "SELECT actor_subject, action, decision "
  139. "FROM public.workflow_plan_audits "
  140. "WHERE workflow_version_id = CAST(:id AS uuid)"
  141. ),
  142. {"id": candidate_id},
  143. ).one()
  144. assert tuple(row) == (
  145. "agent-scheduler",
  146. "deploy_disabled_version",
  147. "allowed",
  148. )
  149. finally:
  150. if candidate_id is not None:
  151. with engine.begin() as connection:
  152. connection.execute(
  153. text(
  154. "DELETE FROM public.workflow_gateway_operations "
  155. "WHERE workflow_version_id = CAST(:id AS uuid)"
  156. ),
  157. {"id": candidate_id},
  158. )
  159. connection.execute(
  160. text(
  161. "DELETE FROM public.workflow_plan_audits "
  162. "WHERE workflow_version_id = CAST(:id AS uuid)"
  163. ),
  164. {"id": candidate_id},
  165. )
  166. connection.execute(
  167. text(
  168. "DELETE FROM public.dataflow_workflow_versions "
  169. "WHERE id = CAST(:id AS uuid)"
  170. ),
  171. {"id": candidate_id},
  172. )
  173. engine.dispose()
  174. def test_idempotency_key_cannot_replay_another_workflow_version():
  175. database_url = os.environ["DATAOPS_MCP_TEST_DATABASE_URL"]
  176. engine = create_engine(database_url, pool_pre_ping=True)
  177. store = PostgresSchedulingPlanStore(engine)
  178. correlation_id = new_governance_uid()
  179. candidate_ids = []
  180. key = f"promotion:v53:scope:{new_governance_uid()}"
  181. try:
  182. for suffix in ("1", "2"):
  183. dataflow_uid = new_governance_uid()
  184. candidate = store.create_candidate(
  185. {
  186. "dataflow_uid": dataflow_uid,
  187. "business_domain": "sales",
  188. "environment": "test",
  189. "workflow_spec": workflow_spec(dataflow_uid),
  190. "schedule_plan": schedule_plan(),
  191. "workflow_hash": suffix * 64,
  192. "status": "candidate",
  193. "created_by": "agent-scheduler",
  194. "correlation_id": correlation_id,
  195. }
  196. )
  197. candidate_ids.append(candidate["candidate_id"])
  198. store.record_deployment(
  199. candidate["candidate_id"],
  200. {
  201. "candidate_id": candidate["candidate_id"],
  202. "namespace": "dataops.test",
  203. "flow_id": f"idempotency-scope-{suffix}",
  204. "definition_hash": suffix * 64,
  205. "engine_revision": f"revision-{suffix}",
  206. "status": "deployed_disabled",
  207. },
  208. )
  209. store.claim_promotion(
  210. key,
  211. candidate_ids[0],
  212. actor_subject="agent-scheduler",
  213. correlation_id=correlation_id,
  214. )
  215. with pytest.raises(ValueError, match="another workflow version"):
  216. store.claim_promotion(
  217. key,
  218. candidate_ids[1],
  219. actor_subject="agent-scheduler",
  220. correlation_id=correlation_id,
  221. )
  222. finally:
  223. with engine.begin() as connection:
  224. connection.execute(
  225. text(
  226. "DELETE FROM public.workflow_gateway_operations "
  227. "WHERE idempotency_key = :key"
  228. ),
  229. {"key": key},
  230. )
  231. if candidate_ids:
  232. connection.execute(
  233. text(
  234. "DELETE FROM public.dataflow_workflow_versions "
  235. "WHERE id = ANY(CAST(:ids AS uuid[]))"
  236. ),
  237. {"ids": candidate_ids},
  238. )
  239. engine.dispose()
  240. def test_postgres_store_completes_promotion_pause_and_rollback_state():
  241. database_url = os.environ["DATAOPS_MCP_TEST_DATABASE_URL"]
  242. engine = create_engine(database_url, pool_pre_ping=True)
  243. store = PostgresSchedulingPlanStore(engine)
  244. dataflow_uid = new_governance_uid()
  245. correlation_id = new_governance_uid()
  246. candidate_ids = []
  247. try:
  248. for suffix in ("previous", "current"):
  249. candidate = store.create_candidate(
  250. {
  251. "dataflow_uid": dataflow_uid,
  252. "business_domain": "sales",
  253. "environment": "test",
  254. "workflow_spec": workflow_spec(dataflow_uid),
  255. "schedule_plan": schedule_plan(),
  256. "workflow_hash": (
  257. "c" * 63 + ("1" if suffix == "previous" else "2")
  258. ),
  259. "status": "candidate",
  260. "created_by": "agent-scheduler",
  261. "correlation_id": correlation_id,
  262. }
  263. )
  264. candidate_ids.append(candidate["candidate_id"])
  265. store.record_deployment(
  266. candidate["candidate_id"],
  267. {
  268. "candidate_id": candidate["candidate_id"],
  269. "namespace": "dataops.test",
  270. "flow_id": f"flow-{suffix}-{candidate['version_no']}",
  271. "definition_hash": "d" * 64,
  272. "engine_revision": f"revision-{suffix}",
  273. "status": "deployed_disabled",
  274. },
  275. )
  276. previous_id, current_id = candidate_ids
  277. promotion_key = f"promotion:{previous_id}"
  278. claimed, promotion = store.claim_promotion(
  279. promotion_key,
  280. previous_id,
  281. actor_subject="agent-scheduler",
  282. correlation_id=correlation_id,
  283. )
  284. assert claimed is True
  285. store.complete_promotion(promotion_key, previous_id, promotion)
  286. store.mark_paused(previous_id)
  287. with engine.connect() as connection:
  288. states = connection.execute(
  289. text(
  290. "SELECT v.status, b.role, b.status, s.status "
  291. "FROM public.dataflow_workflow_versions v "
  292. "JOIN public.workflow_engine_bindings b "
  293. "ON b.workflow_version_id = v.id "
  294. "JOIN public.workflow_schedules s "
  295. "ON s.workflow_version_id = v.id "
  296. "WHERE v.id = CAST(:id AS uuid)"
  297. ),
  298. {"id": previous_id},
  299. ).one()
  300. assert tuple(states) == ("active", "primary", "disabled", "paused")
  301. previous = store.get_previous_deployment(current_id)
  302. assert previous["candidate_id"] == previous_id
  303. rollback_key = f"rollback:{current_id}"
  304. claimed, rollback = store.claim_rollback(
  305. rollback_key,
  306. current_id,
  307. actor_subject="agent-scheduler",
  308. correlation_id=correlation_id,
  309. )
  310. assert claimed is True
  311. store.complete_rollback(rollback_key, current_id, rollback)
  312. with engine.connect() as connection:
  313. operation_status = connection.execute(
  314. text(
  315. "SELECT status FROM public.workflow_gateway_operations "
  316. "WHERE action = 'rollback_to_previous_version' "
  317. "AND idempotency_key = :key"
  318. ),
  319. {"key": rollback_key},
  320. ).scalar_one()
  321. assert operation_status == "succeeded"
  322. finally:
  323. if candidate_ids:
  324. with engine.begin() as connection:
  325. connection.execute(
  326. text(
  327. "DELETE FROM public.workflow_gateway_operations "
  328. "WHERE workflow_version_id = ANY(CAST(:ids AS uuid[]))"
  329. ),
  330. {"ids": candidate_ids},
  331. )
  332. connection.execute(
  333. text(
  334. "DELETE FROM public.workflow_plan_audits "
  335. "WHERE workflow_version_id = ANY(CAST(:ids AS uuid[]))"
  336. ),
  337. {"ids": candidate_ids},
  338. )
  339. connection.execute(
  340. text(
  341. "DELETE FROM public.dataflow_workflow_versions "
  342. "WHERE id = ANY(CAST(:ids AS uuid[]))"
  343. ),
  344. {"ids": candidate_ids},
  345. )
  346. engine.dispose()
  347. def test_postgres_store_tracks_retries_and_bounded_backfill_runs():
  348. database_url = os.environ["DATAOPS_MCP_TEST_DATABASE_URL"]
  349. engine = create_engine(database_url, pool_pre_ping=True)
  350. store = PostgresSchedulingPlanStore(engine)
  351. dataflow_uid = new_governance_uid()
  352. correlation_id = new_governance_uid()
  353. candidate_id = None
  354. original_run_id = new_governance_uid()
  355. try:
  356. candidate = store.create_candidate(
  357. {
  358. "dataflow_uid": dataflow_uid,
  359. "business_domain": "sales",
  360. "environment": "test",
  361. "workflow_spec": workflow_spec(dataflow_uid),
  362. "schedule_plan": schedule_plan(
  363. triggers=[{"type": "cron", "expression": "0 0 * * *"}]
  364. ),
  365. "workflow_hash": "e" * 64,
  366. "status": "candidate",
  367. "created_by": "agent-scheduler",
  368. "correlation_id": correlation_id,
  369. }
  370. )
  371. candidate_id = candidate["candidate_id"]
  372. store.record_deployment(
  373. candidate_id,
  374. {
  375. "candidate_id": candidate_id,
  376. "namespace": "dataops.test",
  377. "flow_id": f"retry-backfill-{candidate['version_no']}",
  378. "definition_hash": "f" * 64,
  379. "engine_revision": "revision-retry",
  380. "status": "deployed_disabled",
  381. },
  382. )
  383. with engine.begin() as connection:
  384. connection.execute(
  385. text(
  386. "INSERT INTO public.workflow_runs "
  387. "(id, workflow_version_id, engine_type, "
  388. "engine_execution_id, trigger_type, status, "
  389. "correlation_id, attempt, run_metadata) "
  390. "VALUES (CAST(:id AS uuid), CAST(:version_id AS uuid), "
  391. "'kestra', 'failed-execution-1', 'manual', 'failed', "
  392. "CAST(:correlation_id AS uuid), 1, '{}'::jsonb)"
  393. ),
  394. {
  395. "id": original_run_id,
  396. "version_id": candidate_id,
  397. "correlation_id": correlation_id,
  398. },
  399. )
  400. record = store.get_execution_record("failed-execution-1")
  401. assert record["retry_count"] == 0
  402. assert record["business_domain"] == "sales"
  403. store.record_retry(
  404. "failed-execution-1",
  405. {"id": "replay-execution-1"},
  406. )
  407. assert store.get_execution_record("failed-execution-1")["retry_count"] == 1
  408. estimated = store.estimate_backfill_runs(
  409. candidate_id,
  410. "2026-07-01T00:00:00Z",
  411. "2026-07-03T00:00:00Z",
  412. )
  413. assert estimated == 2
  414. store.record_backfill(
  415. candidate_id,
  416. {"id": "backfill-execution-1", "estimated_runs": estimated},
  417. )
  418. with engine.connect() as connection:
  419. trigger_type = connection.execute(
  420. text(
  421. "SELECT trigger_type FROM public.workflow_runs "
  422. "WHERE engine_execution_id = 'backfill-execution-1'"
  423. )
  424. ).scalar_one()
  425. assert trigger_type == "backfill"
  426. finally:
  427. if candidate_id is not None:
  428. with engine.begin() as connection:
  429. connection.execute(
  430. text(
  431. "DELETE FROM public.workflow_runs "
  432. "WHERE workflow_version_id = CAST(:id AS uuid)"
  433. ),
  434. {"id": candidate_id},
  435. )
  436. connection.execute(
  437. text(
  438. "DELETE FROM public.workflow_gateway_operations "
  439. "WHERE workflow_version_id = CAST(:id AS uuid)"
  440. ),
  441. {"id": candidate_id},
  442. )
  443. connection.execute(
  444. text(
  445. "DELETE FROM public.dataflow_workflow_versions "
  446. "WHERE id = CAST(:id AS uuid)"
  447. ),
  448. {"id": candidate_id},
  449. )
  450. engine.dispose()
  451. def test_postgres_context_read_model_exposes_only_bounded_operational_data():
  452. database_url = os.environ["DATAOPS_MCP_TEST_DATABASE_URL"]
  453. engine = create_engine(database_url, pool_pre_ping=True)
  454. store = PostgresSchedulingPlanStore(engine)
  455. repository = PostgresContextRepository(engine, max_concurrency=5)
  456. dataflow_uid = new_governance_uid()
  457. correlation_id = new_governance_uid()
  458. candidate_id = None
  459. execution_id = f"context-{new_governance_uid()}"
  460. try:
  461. candidate = store.create_candidate(
  462. {
  463. "dataflow_uid": dataflow_uid,
  464. "business_domain": "sales",
  465. "environment": "test",
  466. "workflow_spec": workflow_spec(dataflow_uid),
  467. "schedule_plan": schedule_plan(
  468. triggers=[{"type": "cron", "expression": "0 0 * * *"}]
  469. ),
  470. "workflow_hash": "9" * 64,
  471. "status": "candidate",
  472. "created_by": "agent-context",
  473. "correlation_id": correlation_id,
  474. }
  475. )
  476. candidate_id = candidate["candidate_id"]
  477. with engine.begin() as connection:
  478. connection.execute(
  479. text(
  480. "INSERT INTO public.workflow_runs "
  481. "(id, workflow_version_id, engine_type, "
  482. "engine_execution_id, trigger_type, status, "
  483. "correlation_id, attempt, run_metadata) "
  484. "VALUES (CAST(:id AS uuid), CAST(:version_id AS uuid), "
  485. "'kestra', :execution_id, 'manual', 'success', "
  486. "CAST(:correlation_id AS uuid), 1, '{}'::jsonb)"
  487. ),
  488. {
  489. "id": new_governance_uid(),
  490. "version_id": candidate_id,
  491. "execution_id": execution_id,
  492. "correlation_id": correlation_id,
  493. },
  494. )
  495. assert repository.describe_dataflow(dataflow_uid)["business_domain"] == "sales"
  496. assert any(row["uid"] == dataflow_uid for row in repository.list_dataflows())
  497. assert repository.get_sla_constraints(dataflow_uid) == {
  498. "timezone": "Asia/Shanghai",
  499. "max_duration_seconds": 300,
  500. }
  501. history = repository.get_execution_history(dataflow_uid, limit=10, days=1)
  502. assert history[0]["status"] == "success"
  503. capacity = repository.estimate_schedule_capacity("sales", schedule_plan())
  504. assert capacity["available_slots"] <= 5
  505. simulation = repository.simulate_schedule(
  506. "sales",
  507. schedule_plan(triggers=[{"type": "cron", "expression": "0 0 * * *"}]),
  508. )
  509. assert len(simulation["next_runs"]) == 5
  510. finally:
  511. if candidate_id is not None:
  512. with engine.begin() as connection:
  513. connection.execute(
  514. text(
  515. "DELETE FROM public.workflow_runs "
  516. "WHERE workflow_version_id = CAST(:id AS uuid)"
  517. ),
  518. {"id": candidate_id},
  519. )
  520. connection.execute(
  521. text(
  522. "DELETE FROM public.dataflow_workflow_versions "
  523. "WHERE id = CAST(:id AS uuid)"
  524. ),
  525. {"id": candidate_id},
  526. )
  527. engine.dispose()