test_mcp_gateway_persistence.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  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": "ai_planning_closed_loop",
  131. "decision": "allowed",
  132. "model_provider": "deepseek",
  133. "model_name": "deepseek-chat",
  134. "prompt_version": "v54.1",
  135. "schema_version": "v54",
  136. "context_hash": "c" * 64,
  137. "detail": {
  138. "candidate_id": candidate_id,
  139. "workflow_hash": "a" * 64,
  140. "candidate_plan": {"schema_version": "1.0"},
  141. },
  142. }
  143. )
  144. with engine.connect() as connection:
  145. row = connection.execute(
  146. text(
  147. "SELECT actor_subject, action, decision, model_provider, "
  148. "model_name, prompt_version, schema_version, context_hash, "
  149. "candidate_hash, decision_detail "
  150. "FROM public.workflow_plan_audits "
  151. "WHERE workflow_version_id = CAST(:id AS uuid)"
  152. ),
  153. {"id": candidate_id},
  154. ).one()
  155. assert tuple(row[:9]) == (
  156. "agent-scheduler",
  157. "ai_planning_closed_loop",
  158. "allowed",
  159. "deepseek",
  160. "deepseek-chat",
  161. "v54.1",
  162. "v54",
  163. "c" * 64,
  164. "a" * 64,
  165. )
  166. assert row.decision_detail["candidate_plan"]["schema_version"] == "1.0"
  167. finally:
  168. if candidate_id is not None:
  169. with engine.begin() as connection:
  170. connection.execute(
  171. text(
  172. "DELETE FROM public.workflow_gateway_operations "
  173. "WHERE workflow_version_id = CAST(:id AS uuid)"
  174. ),
  175. {"id": candidate_id},
  176. )
  177. connection.execute(
  178. text(
  179. "DELETE FROM public.workflow_plan_audits "
  180. "WHERE workflow_version_id = CAST(:id AS uuid)"
  181. ),
  182. {"id": candidate_id},
  183. )
  184. connection.execute(
  185. text(
  186. "DELETE FROM public.dataflow_workflow_versions "
  187. "WHERE id = CAST(:id AS uuid)"
  188. ),
  189. {"id": candidate_id},
  190. )
  191. engine.dispose()
  192. def test_idempotency_key_cannot_replay_another_workflow_version():
  193. database_url = os.environ["DATAOPS_MCP_TEST_DATABASE_URL"]
  194. engine = create_engine(database_url, pool_pre_ping=True)
  195. store = PostgresSchedulingPlanStore(engine)
  196. correlation_id = new_governance_uid()
  197. candidate_ids = []
  198. key = f"promotion:v53:scope:{new_governance_uid()}"
  199. try:
  200. for suffix in ("1", "2"):
  201. dataflow_uid = new_governance_uid()
  202. candidate = store.create_candidate(
  203. {
  204. "dataflow_uid": dataflow_uid,
  205. "business_domain": "sales",
  206. "environment": "test",
  207. "workflow_spec": workflow_spec(dataflow_uid),
  208. "schedule_plan": schedule_plan(),
  209. "workflow_hash": suffix * 64,
  210. "status": "candidate",
  211. "created_by": "agent-scheduler",
  212. "correlation_id": correlation_id,
  213. }
  214. )
  215. candidate_ids.append(candidate["candidate_id"])
  216. store.record_deployment(
  217. candidate["candidate_id"],
  218. {
  219. "candidate_id": candidate["candidate_id"],
  220. "namespace": "dataops.test",
  221. "flow_id": f"idempotency-scope-{suffix}",
  222. "definition_hash": suffix * 64,
  223. "engine_revision": f"revision-{suffix}",
  224. "status": "deployed_disabled",
  225. },
  226. )
  227. store.claim_promotion(
  228. key,
  229. candidate_ids[0],
  230. actor_subject="agent-scheduler",
  231. correlation_id=correlation_id,
  232. )
  233. with pytest.raises(ValueError, match="another workflow version"):
  234. store.claim_promotion(
  235. key,
  236. candidate_ids[1],
  237. actor_subject="agent-scheduler",
  238. correlation_id=correlation_id,
  239. )
  240. finally:
  241. with engine.begin() as connection:
  242. connection.execute(
  243. text(
  244. "DELETE FROM public.workflow_gateway_operations "
  245. "WHERE idempotency_key = :key"
  246. ),
  247. {"key": key},
  248. )
  249. if candidate_ids:
  250. connection.execute(
  251. text(
  252. "DELETE FROM public.dataflow_workflow_versions "
  253. "WHERE id = ANY(CAST(:ids AS uuid[]))"
  254. ),
  255. {"ids": candidate_ids},
  256. )
  257. engine.dispose()
  258. def test_postgres_store_completes_promotion_pause_and_rollback_state():
  259. database_url = os.environ["DATAOPS_MCP_TEST_DATABASE_URL"]
  260. engine = create_engine(database_url, pool_pre_ping=True)
  261. store = PostgresSchedulingPlanStore(engine)
  262. dataflow_uid = new_governance_uid()
  263. correlation_id = new_governance_uid()
  264. candidate_ids = []
  265. try:
  266. for suffix in ("previous", "current"):
  267. candidate = store.create_candidate(
  268. {
  269. "dataflow_uid": dataflow_uid,
  270. "business_domain": "sales",
  271. "environment": "test",
  272. "workflow_spec": workflow_spec(dataflow_uid),
  273. "schedule_plan": schedule_plan(),
  274. "workflow_hash": (
  275. "c" * 63 + ("1" if suffix == "previous" else "2")
  276. ),
  277. "status": "candidate",
  278. "created_by": "agent-scheduler",
  279. "correlation_id": correlation_id,
  280. }
  281. )
  282. candidate_ids.append(candidate["candidate_id"])
  283. store.record_deployment(
  284. candidate["candidate_id"],
  285. {
  286. "candidate_id": candidate["candidate_id"],
  287. "namespace": "dataops.test",
  288. "flow_id": f"flow-{suffix}-{candidate['version_no']}",
  289. "definition_hash": "d" * 64,
  290. "engine_revision": f"revision-{suffix}",
  291. "status": "deployed_disabled",
  292. },
  293. )
  294. previous_id, current_id = candidate_ids
  295. promotion_key = f"promotion:{previous_id}"
  296. claimed, promotion = store.claim_promotion(
  297. promotion_key,
  298. previous_id,
  299. actor_subject="agent-scheduler",
  300. correlation_id=correlation_id,
  301. )
  302. assert claimed is True
  303. store.complete_promotion(promotion_key, previous_id, promotion)
  304. store.mark_paused(previous_id)
  305. with engine.connect() as connection:
  306. states = connection.execute(
  307. text(
  308. "SELECT v.status, b.role, b.status, s.status "
  309. "FROM public.dataflow_workflow_versions v "
  310. "JOIN public.workflow_engine_bindings b "
  311. "ON b.workflow_version_id = v.id "
  312. "JOIN public.workflow_schedules s "
  313. "ON s.workflow_version_id = v.id "
  314. "WHERE v.id = CAST(:id AS uuid)"
  315. ),
  316. {"id": previous_id},
  317. ).one()
  318. assert tuple(states) == ("active", "primary", "disabled", "paused")
  319. previous = store.get_previous_deployment(current_id)
  320. assert previous["candidate_id"] == previous_id
  321. rollback_key = f"rollback:{current_id}"
  322. claimed, rollback = store.claim_rollback(
  323. rollback_key,
  324. current_id,
  325. actor_subject="agent-scheduler",
  326. correlation_id=correlation_id,
  327. )
  328. assert claimed is True
  329. store.complete_rollback(rollback_key, current_id, rollback)
  330. with engine.connect() as connection:
  331. operation_status = connection.execute(
  332. text(
  333. "SELECT status FROM public.workflow_gateway_operations "
  334. "WHERE action = 'rollback_to_previous_version' "
  335. "AND idempotency_key = :key"
  336. ),
  337. {"key": rollback_key},
  338. ).scalar_one()
  339. assert operation_status == "succeeded"
  340. finally:
  341. if candidate_ids:
  342. with engine.begin() as connection:
  343. connection.execute(
  344. text(
  345. "DELETE FROM public.workflow_gateway_operations "
  346. "WHERE workflow_version_id = ANY(CAST(:ids AS uuid[]))"
  347. ),
  348. {"ids": candidate_ids},
  349. )
  350. connection.execute(
  351. text(
  352. "DELETE FROM public.workflow_plan_audits "
  353. "WHERE workflow_version_id = ANY(CAST(:ids AS uuid[]))"
  354. ),
  355. {"ids": candidate_ids},
  356. )
  357. connection.execute(
  358. text(
  359. "DELETE FROM public.dataflow_workflow_versions "
  360. "WHERE id = ANY(CAST(:ids AS uuid[]))"
  361. ),
  362. {"ids": candidate_ids},
  363. )
  364. engine.dispose()
  365. def test_postgres_store_tracks_retries_and_bounded_backfill_runs():
  366. database_url = os.environ["DATAOPS_MCP_TEST_DATABASE_URL"]
  367. engine = create_engine(database_url, pool_pre_ping=True)
  368. store = PostgresSchedulingPlanStore(engine)
  369. dataflow_uid = new_governance_uid()
  370. correlation_id = new_governance_uid()
  371. candidate_id = None
  372. original_run_id = new_governance_uid()
  373. try:
  374. candidate = store.create_candidate(
  375. {
  376. "dataflow_uid": dataflow_uid,
  377. "business_domain": "sales",
  378. "environment": "test",
  379. "workflow_spec": workflow_spec(dataflow_uid),
  380. "schedule_plan": schedule_plan(
  381. triggers=[{"type": "cron", "expression": "0 0 * * *"}]
  382. ),
  383. "workflow_hash": "e" * 64,
  384. "status": "candidate",
  385. "created_by": "agent-scheduler",
  386. "correlation_id": correlation_id,
  387. }
  388. )
  389. candidate_id = candidate["candidate_id"]
  390. store.record_deployment(
  391. candidate_id,
  392. {
  393. "candidate_id": candidate_id,
  394. "namespace": "dataops.test",
  395. "flow_id": f"retry-backfill-{candidate['version_no']}",
  396. "definition_hash": "f" * 64,
  397. "engine_revision": "revision-retry",
  398. "status": "deployed_disabled",
  399. },
  400. )
  401. with engine.begin() as connection:
  402. connection.execute(
  403. text(
  404. "INSERT INTO public.workflow_runs "
  405. "(id, workflow_version_id, engine_type, "
  406. "engine_execution_id, trigger_type, status, "
  407. "correlation_id, attempt, run_metadata) "
  408. "VALUES (CAST(:id AS uuid), CAST(:version_id AS uuid), "
  409. "'kestra', 'failed-execution-1', 'manual', 'failed', "
  410. "CAST(:correlation_id AS uuid), 1, '{}'::jsonb)"
  411. ),
  412. {
  413. "id": original_run_id,
  414. "version_id": candidate_id,
  415. "correlation_id": correlation_id,
  416. },
  417. )
  418. record = store.get_execution_record("failed-execution-1")
  419. assert record["retry_count"] == 0
  420. assert record["business_domain"] == "sales"
  421. store.record_retry(
  422. "failed-execution-1",
  423. {"id": "replay-execution-1"},
  424. )
  425. assert store.get_execution_record("failed-execution-1")["retry_count"] == 1
  426. estimated = store.estimate_backfill_runs(
  427. candidate_id,
  428. "2026-07-01T00:00:00Z",
  429. "2026-07-03T00:00:00Z",
  430. )
  431. assert estimated == 2
  432. store.record_backfill(
  433. candidate_id,
  434. {"id": "backfill-execution-1", "estimated_runs": estimated},
  435. )
  436. with engine.connect() as connection:
  437. trigger_type = connection.execute(
  438. text(
  439. "SELECT trigger_type FROM public.workflow_runs "
  440. "WHERE engine_execution_id = 'backfill-execution-1'"
  441. )
  442. ).scalar_one()
  443. assert trigger_type == "backfill"
  444. finally:
  445. if candidate_id is not None:
  446. with engine.begin() as connection:
  447. connection.execute(
  448. text(
  449. "DELETE FROM public.workflow_runs "
  450. "WHERE workflow_version_id = CAST(:id AS uuid)"
  451. ),
  452. {"id": candidate_id},
  453. )
  454. connection.execute(
  455. text(
  456. "DELETE FROM public.workflow_gateway_operations "
  457. "WHERE workflow_version_id = CAST(:id AS uuid)"
  458. ),
  459. {"id": candidate_id},
  460. )
  461. connection.execute(
  462. text(
  463. "DELETE FROM public.dataflow_workflow_versions "
  464. "WHERE id = CAST(:id AS uuid)"
  465. ),
  466. {"id": candidate_id},
  467. )
  468. engine.dispose()
  469. def test_postgres_context_read_model_exposes_only_bounded_operational_data():
  470. database_url = os.environ["DATAOPS_MCP_TEST_DATABASE_URL"]
  471. engine = create_engine(database_url, pool_pre_ping=True)
  472. store = PostgresSchedulingPlanStore(engine)
  473. repository = PostgresContextRepository(engine, max_concurrency=5)
  474. dataflow_uid = new_governance_uid()
  475. correlation_id = new_governance_uid()
  476. candidate_id = None
  477. execution_id = f"context-{new_governance_uid()}"
  478. try:
  479. candidate = store.create_candidate(
  480. {
  481. "dataflow_uid": dataflow_uid,
  482. "business_domain": "sales",
  483. "environment": "test",
  484. "workflow_spec": workflow_spec(dataflow_uid),
  485. "schedule_plan": schedule_plan(
  486. triggers=[{"type": "cron", "expression": "0 0 * * *"}]
  487. ),
  488. "workflow_hash": "9" * 64,
  489. "status": "candidate",
  490. "created_by": "agent-context",
  491. "correlation_id": correlation_id,
  492. }
  493. )
  494. candidate_id = candidate["candidate_id"]
  495. with engine.begin() as connection:
  496. connection.execute(
  497. text(
  498. "INSERT INTO public.workflow_runs "
  499. "(id, workflow_version_id, engine_type, "
  500. "engine_execution_id, trigger_type, status, "
  501. "correlation_id, attempt, run_metadata) "
  502. "VALUES (CAST(:id AS uuid), CAST(:version_id AS uuid), "
  503. "'kestra', :execution_id, 'manual', 'success', "
  504. "CAST(:correlation_id AS uuid), 1, '{}'::jsonb)"
  505. ),
  506. {
  507. "id": new_governance_uid(),
  508. "version_id": candidate_id,
  509. "execution_id": execution_id,
  510. "correlation_id": correlation_id,
  511. },
  512. )
  513. assert repository.describe_dataflow(dataflow_uid)["business_domain"] == "sales"
  514. assert any(row["uid"] == dataflow_uid for row in repository.list_dataflows())
  515. assert repository.get_sla_constraints(dataflow_uid) == {
  516. "timezone": "Asia/Shanghai",
  517. "max_duration_seconds": 300,
  518. }
  519. history = repository.get_execution_history(dataflow_uid, limit=10, days=1)
  520. assert history[0]["status"] == "success"
  521. capacity = repository.estimate_schedule_capacity("sales", schedule_plan())
  522. assert capacity["available_slots"] <= 5
  523. simulation = repository.simulate_schedule(
  524. "sales",
  525. schedule_plan(triggers=[{"type": "cron", "expression": "0 0 * * *"}]),
  526. )
  527. assert len(simulation["next_runs"]) == 5
  528. finally:
  529. if candidate_id is not None:
  530. with engine.begin() as connection:
  531. connection.execute(
  532. text(
  533. "DELETE FROM public.workflow_runs "
  534. "WHERE workflow_version_id = CAST(:id AS uuid)"
  535. ),
  536. {"id": candidate_id},
  537. )
  538. connection.execute(
  539. text(
  540. "DELETE FROM public.dataflow_workflow_versions "
  541. "WHERE id = CAST(:id AS uuid)"
  542. ),
  543. {"id": candidate_id},
  544. )
  545. engine.dispose()