test_workflow_migration_persistence.py 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  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.orchestration.migration.dual_run import DualRunMode
  6. from app.core.orchestration.migration.repository import PostgresMigrationStore
  7. pytestmark = pytest.mark.skipif(
  8. os.getenv("RUN_V55_PERSISTENCE_INTEGRATION") != "1",
  9. reason="set RUN_V55_PERSISTENCE_INTEGRATION=1",
  10. )
  11. def test_cutover_state_and_outbox_commit_atomically_and_idempotently():
  12. engine = create_engine(
  13. os.environ["DATAOPS_MCP_TEST_DATABASE_URL"], pool_pre_ping=True
  14. )
  15. store = PostgresMigrationStore(engine)
  16. dataflow_uid = new_governance_uid()
  17. correlation_id = new_governance_uid()
  18. idempotency_key = f"cutover:{dataflow_uid}:v55"
  19. state = None
  20. operation = None
  21. try:
  22. state = store.create_state(
  23. {
  24. "dataflow_uid": dataflow_uid,
  25. "environment": "test",
  26. "mode": DualRunMode.N8N_PRIMARY_KESTRA_SHADOW.value,
  27. "n8n_definition_id": f"n8n-{dataflow_uid}",
  28. "kestra_definition_id": f"kestra-{dataflow_uid}",
  29. "migration_batch": "low_risk",
  30. }
  31. )
  32. dual_run_id = store.record_dual_run(
  33. {
  34. "dataflow_uid": dataflow_uid,
  35. "environment": "test",
  36. "input_snapshot_hash": "a" * 64,
  37. "primary_execution_id": "n8n-v55-persistence",
  38. "shadow_execution_id": "kestra-v55-persistence",
  39. "primary_status": "success",
  40. "shadow_status": "success",
  41. "isolation": {"strategy": "read_only"},
  42. "formal_engine_changed": False,
  43. "correlation_id": correlation_id,
  44. }
  45. )
  46. report_id = store.record_reconciliation(
  47. {
  48. "state_id": state["state_id"],
  49. "dual_run_id": dual_run_id,
  50. "status": "passed",
  51. "policy": {},
  52. "safe_metrics": {"row_count_delta": 0},
  53. "differences": [],
  54. "correlation_id": correlation_id,
  55. }
  56. )
  57. assert dual_run_id
  58. assert report_id
  59. record = {
  60. "dataflow_uid": dataflow_uid,
  61. "environment": "test",
  62. "source_mode": DualRunMode.N8N_PRIMARY_KESTRA_SHADOW.value,
  63. "target_mode": DualRunMode.KESTRA_PRIMARY_N8N_STANDBY.value,
  64. "n8n_definition_id": state["n8n_definition_id"],
  65. "kestra_definition_id": state["kestra_definition_id"],
  66. "gates": {"reconciliation_passed": True},
  67. "idempotency_key": idempotency_key,
  68. "correlation_id": correlation_id,
  69. }
  70. claimed, operation = store.request_transition(record)
  71. replay_claimed, replay = store.request_transition(record)
  72. assert claimed is True
  73. assert replay_claimed is False
  74. assert replay["operation_id"] == operation["operation_id"]
  75. with engine.connect() as connection:
  76. event = connection.execute(
  77. text(
  78. "SELECT event_type, payload "
  79. "FROM public.outbox_events "
  80. "WHERE aggregate_id = :operation_id"
  81. ),
  82. {"operation_id": operation["operation_id"]},
  83. ).one()
  84. assert event[0] == "workflow.engine.cutover.requested"
  85. assert event[1]["idempotency_key"] == idempotency_key
  86. assert "password" not in repr(event[1]).lower()
  87. store.complete_transition(
  88. operation["operation_id"],
  89. DualRunMode.KESTRA_PRIMARY_N8N_STANDBY.value,
  90. {"status": "succeeded"},
  91. )
  92. current = store.get_state(dataflow_uid, "test")
  93. assert current["mode"] == "kestra_primary_n8n_standby"
  94. assert current["status"] == "stable"
  95. finally:
  96. if state:
  97. store.delete_test_state(state["state_id"])
  98. engine.dispose()