|
@@ -0,0 +1,549 @@
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+import json
|
|
|
|
|
+from typing import Any
|
|
|
|
|
+
|
|
|
|
|
+from sqlalchemy import text
|
|
|
|
|
+
|
|
|
|
|
+from app.core.common.identifiers import ensure_governance_uid, new_governance_uid
|
|
|
|
|
+from app.core.events.outbox import enqueue_outbox
|
|
|
|
|
+from app.core.orchestration.migration.dual_run import DualRunMode
|
|
|
|
|
+
|
|
|
|
|
+ENVIRONMENTS = {"development", "test", "production"}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _json(value: Any) -> str:
|
|
|
|
|
+ return json.dumps(
|
|
|
|
|
+ value,
|
|
|
|
|
+ sort_keys=True,
|
|
|
|
|
+ separators=(",", ":"),
|
|
|
|
|
+ ensure_ascii=False,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _required_string(value: Any, label: str, maximum: int = 500) -> str:
|
|
|
|
|
+ if not isinstance(value, str) or not value.strip():
|
|
|
|
|
+ raise ValueError(f"{label} is required")
|
|
|
|
|
+ normalized = value.strip()
|
|
|
|
|
+ if len(normalized) > maximum:
|
|
|
|
|
+ raise ValueError(f"{label} exceeds {maximum} characters")
|
|
|
|
|
+ return normalized
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _uid(value: Any, label: str) -> str:
|
|
|
|
|
+ try:
|
|
|
|
|
+ return ensure_governance_uid({"uid": str(value)})
|
|
|
|
|
+ except ValueError as exc:
|
|
|
|
|
+ raise ValueError(f"{label} must be a valid UUIDv7") from exc
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _state_row(row) -> dict[str, Any]:
|
|
|
|
|
+ return {
|
|
|
|
|
+ "state_id": row[0],
|
|
|
|
|
+ "dataflow_uid": row[1],
|
|
|
|
|
+ "environment": row[2],
|
|
|
|
|
+ "mode": row[3],
|
|
|
|
|
+ "status": row[4],
|
|
|
|
|
+ "n8n_definition_id": row[5],
|
|
|
|
|
+ "kestra_definition_id": row[6],
|
|
|
|
|
+ "migration_batch": row[7],
|
|
|
|
|
+ "observation_until": row[8],
|
|
|
|
|
+ "metadata": dict(row[9] or {}),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _operation_row(row, state) -> dict[str, Any]:
|
|
|
|
|
+ return {
|
|
|
|
|
+ "operation_id": row[0],
|
|
|
|
|
+ "dataflow_uid": state["dataflow_uid"],
|
|
|
|
|
+ "environment": state["environment"],
|
|
|
|
|
+ "source_mode": row[1],
|
|
|
|
|
+ "target_mode": row[2],
|
|
|
|
|
+ "n8n_definition_id": state["n8n_definition_id"],
|
|
|
|
|
+ "kestra_definition_id": state["kestra_definition_id"],
|
|
|
|
|
+ "gates": dict(row[3] or {}),
|
|
|
|
|
+ "idempotency_key": row[4],
|
|
|
|
|
+ "correlation_id": row[5],
|
|
|
|
|
+ "status": row[6],
|
|
|
|
|
+ "result": dict(row[7] or {}),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class PostgresMigrationStore:
|
|
|
|
|
+ def __init__(self, engine):
|
|
|
|
|
+ self.engine = engine
|
|
|
|
|
+
|
|
|
|
|
+ def create_state(self, record):
|
|
|
|
|
+ dataflow_uid = _uid(record.get("dataflow_uid"), "dataflow_uid")
|
|
|
|
|
+ environment = _required_string(
|
|
|
|
|
+ record.get("environment"), "environment", 20
|
|
|
|
|
+ )
|
|
|
|
|
+ if environment not in ENVIRONMENTS:
|
|
|
|
|
+ raise ValueError("unsupported environment")
|
|
|
|
|
+ mode = DualRunMode(record.get("mode")).value
|
|
|
|
|
+ n8n_id = record.get("n8n_definition_id")
|
|
|
|
|
+ kestra_id = record.get("kestra_definition_id")
|
|
|
|
|
+ if mode != DualRunMode.KESTRA_PRIMARY.value:
|
|
|
|
|
+ n8n_id = _required_string(n8n_id, "n8n_definition_id", 255)
|
|
|
|
|
+ if mode != DualRunMode.N8N_PRIMARY.value:
|
|
|
|
|
+ kestra_id = _required_string(kestra_id, "kestra_definition_id", 255)
|
|
|
|
|
+ migration_batch = record.get("migration_batch")
|
|
|
|
|
+ if migration_batch is not None:
|
|
|
|
|
+ migration_batch = _required_string(
|
|
|
|
|
+ migration_batch, "migration_batch", 50
|
|
|
|
|
+ )
|
|
|
|
|
+ metadata = record.get("metadata") or {}
|
|
|
|
|
+ if not isinstance(metadata, dict):
|
|
|
|
|
+ raise ValueError("metadata must be an object")
|
|
|
|
|
+
|
|
|
|
|
+ state_id = new_governance_uid()
|
|
|
|
|
+ with self.engine.begin() as connection:
|
|
|
|
|
+ connection.execute(
|
|
|
|
|
+ text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
|
|
|
|
|
+ {"key": f"{dataflow_uid}:{environment}:migration"},
|
|
|
|
|
+ )
|
|
|
|
|
+ connection.execute(
|
|
|
|
|
+ text(
|
|
|
|
|
+ "INSERT INTO public.workflow_migration_states "
|
|
|
|
|
+ "(id, dataflow_uid, environment, mode, status, "
|
|
|
|
|
+ "n8n_definition_id, kestra_definition_id, "
|
|
|
|
|
+ "migration_batch, metadata) "
|
|
|
|
|
+ "VALUES (CAST(:id AS uuid), CAST(:dataflow_uid AS uuid), "
|
|
|
|
|
+ ":environment, :mode, 'stable', :n8n_id, :kestra_id, "
|
|
|
|
|
+ ":migration_batch, CAST(:metadata AS jsonb))"
|
|
|
|
|
+ ),
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": state_id,
|
|
|
|
|
+ "dataflow_uid": dataflow_uid,
|
|
|
|
|
+ "environment": environment,
|
|
|
|
|
+ "mode": mode,
|
|
|
|
|
+ "n8n_id": n8n_id,
|
|
|
|
|
+ "kestra_id": kestra_id,
|
|
|
|
|
+ "migration_batch": migration_batch,
|
|
|
|
|
+ "metadata": _json(metadata),
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ return self.get_state(dataflow_uid, environment)
|
|
|
|
|
+
|
|
|
|
|
+ def get_state(self, dataflow_uid, environment):
|
|
|
|
|
+ dataflow_uid = _uid(dataflow_uid, "dataflow_uid")
|
|
|
|
|
+ environment = _required_string(environment, "environment", 20)
|
|
|
|
|
+ with self.engine.connect() as connection:
|
|
|
|
|
+ row = connection.execute(
|
|
|
|
|
+ text(
|
|
|
|
|
+ "SELECT id::text, dataflow_uid::text, environment, mode, "
|
|
|
|
|
+ "status, n8n_definition_id, kestra_definition_id, "
|
|
|
|
|
+ "migration_batch, observation_until, metadata "
|
|
|
|
|
+ "FROM public.workflow_migration_states "
|
|
|
|
|
+ "WHERE dataflow_uid = CAST(:dataflow_uid AS uuid) "
|
|
|
|
|
+ "AND environment = :environment"
|
|
|
|
|
+ ),
|
|
|
|
|
+ {
|
|
|
|
|
+ "dataflow_uid": dataflow_uid,
|
|
|
|
|
+ "environment": environment,
|
|
|
|
|
+ },
|
|
|
|
|
+ ).one_or_none()
|
|
|
|
|
+ return _state_row(row) if row else None
|
|
|
|
|
+
|
|
|
|
|
+ @staticmethod
|
|
|
|
|
+ def _load_state(connection, dataflow_uid, environment):
|
|
|
|
|
+ row = connection.execute(
|
|
|
|
|
+ text(
|
|
|
|
|
+ "SELECT id::text, dataflow_uid::text, environment, mode, "
|
|
|
|
|
+ "status, n8n_definition_id, kestra_definition_id, "
|
|
|
|
|
+ "migration_batch, observation_until, metadata "
|
|
|
|
|
+ "FROM public.workflow_migration_states "
|
|
|
|
|
+ "WHERE dataflow_uid = CAST(:dataflow_uid AS uuid) "
|
|
|
|
|
+ "AND environment = :environment FOR UPDATE"
|
|
|
|
|
+ ),
|
|
|
|
|
+ {
|
|
|
|
|
+ "dataflow_uid": dataflow_uid,
|
|
|
|
|
+ "environment": environment,
|
|
|
|
|
+ },
|
|
|
|
|
+ ).one_or_none()
|
|
|
|
|
+ if row is None:
|
|
|
|
|
+ raise ValueError("workflow migration state was not found")
|
|
|
|
|
+ return _state_row(row)
|
|
|
|
|
+
|
|
|
|
|
+ @staticmethod
|
|
|
|
|
+ def _find_operation(connection, state, idempotency_key):
|
|
|
|
|
+ row = connection.execute(
|
|
|
|
|
+ text(
|
|
|
|
|
+ "SELECT id::text, source_mode, target_mode, gates, "
|
|
|
|
|
+ "idempotency_key, correlation_id::text, status, result "
|
|
|
|
|
+ "FROM public.workflow_cutover_operations "
|
|
|
|
|
+ "WHERE migration_state_id = CAST(:state_id AS uuid) "
|
|
|
|
|
+ "AND idempotency_key = :idempotency_key"
|
|
|
|
|
+ ),
|
|
|
|
|
+ {
|
|
|
|
|
+ "state_id": state["state_id"],
|
|
|
|
|
+ "idempotency_key": idempotency_key,
|
|
|
|
|
+ },
|
|
|
|
|
+ ).one_or_none()
|
|
|
|
|
+ return _operation_row(row, state) if row else None
|
|
|
|
|
+
|
|
|
|
|
+ def request_transition(self, record):
|
|
|
|
|
+ dataflow_uid = _uid(record.get("dataflow_uid"), "dataflow_uid")
|
|
|
|
|
+ environment = _required_string(
|
|
|
|
|
+ record.get("environment"), "environment", 20
|
|
|
|
|
+ )
|
|
|
|
|
+ source = DualRunMode(record.get("source_mode")).value
|
|
|
|
|
+ target = DualRunMode(record.get("target_mode")).value
|
|
|
|
|
+ idempotency_key = _required_string(
|
|
|
|
|
+ record.get("idempotency_key"), "idempotency_key", 500
|
|
|
|
|
+ )
|
|
|
|
|
+ correlation_id = _uid(record.get("correlation_id"), "correlation_id")
|
|
|
|
|
+ gates = record.get("gates") or {}
|
|
|
|
|
+ if not isinstance(gates, dict):
|
|
|
|
|
+ raise ValueError("gates must be an object")
|
|
|
|
|
+
|
|
|
|
|
+ with self.engine.begin() as connection:
|
|
|
|
|
+ connection.execute(
|
|
|
|
|
+ text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
|
|
|
|
|
+ {"key": f"{dataflow_uid}:{environment}:migration"},
|
|
|
|
|
+ )
|
|
|
|
|
+ state = self._load_state(connection, dataflow_uid, environment)
|
|
|
|
|
+ previous = self._find_operation(
|
|
|
|
|
+ connection, state, idempotency_key
|
|
|
|
|
+ )
|
|
|
|
|
+ if previous:
|
|
|
|
|
+ return False, previous
|
|
|
|
|
+ if state["mode"] != source or state["status"] != "stable":
|
|
|
|
|
+ raise ValueError("workflow migration state changed")
|
|
|
|
|
+
|
|
|
|
|
+ operation_id = new_governance_uid()
|
|
|
|
|
+ connection.execute(
|
|
|
|
|
+ text(
|
|
|
|
|
+ "INSERT INTO public.workflow_cutover_operations "
|
|
|
|
|
+ "(id, migration_state_id, idempotency_key, source_mode, "
|
|
|
|
|
+ "target_mode, status, gates, correlation_id) "
|
|
|
|
|
+ "VALUES (CAST(:id AS uuid), CAST(:state_id AS uuid), "
|
|
|
|
|
+ ":idempotency_key, :source_mode, :target_mode, 'claimed', "
|
|
|
|
|
+ "CAST(:gates AS jsonb), CAST(:correlation_id AS uuid))"
|
|
|
|
|
+ ),
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": operation_id,
|
|
|
|
|
+ "state_id": state["state_id"],
|
|
|
|
|
+ "idempotency_key": idempotency_key,
|
|
|
|
|
+ "source_mode": source,
|
|
|
|
|
+ "target_mode": target,
|
|
|
|
|
+ "gates": _json(gates),
|
|
|
|
|
+ "correlation_id": correlation_id,
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ connection.execute(
|
|
|
|
|
+ text(
|
|
|
|
|
+ "UPDATE public.workflow_migration_states "
|
|
|
|
|
+ "SET status = 'transition_pending', "
|
|
|
|
|
+ "updated_at = CURRENT_TIMESTAMP "
|
|
|
|
|
+ "WHERE id = CAST(:state_id AS uuid)"
|
|
|
|
|
+ ),
|
|
|
|
|
+ {"state_id": state["state_id"]},
|
|
|
|
|
+ )
|
|
|
|
|
+ event_payload = {
|
|
|
|
|
+ "operation_id": operation_id,
|
|
|
|
|
+ "dataflow_uid": dataflow_uid,
|
|
|
|
|
+ "environment": environment,
|
|
|
|
|
+ "source_mode": source,
|
|
|
|
|
+ "target_mode": target,
|
|
|
|
|
+ "n8n_definition_id": state["n8n_definition_id"],
|
|
|
|
|
+ "kestra_definition_id": state["kestra_definition_id"],
|
|
|
|
|
+ "idempotency_key": idempotency_key,
|
|
|
|
|
+ "correlation_id": correlation_id,
|
|
|
|
|
+ }
|
|
|
|
|
+ enqueue_outbox(
|
|
|
|
|
+ connection,
|
|
|
|
|
+ aggregate_type="workflow_cutover",
|
|
|
|
|
+ aggregate_id=operation_id,
|
|
|
|
|
+ event_type="workflow.engine.cutover.requested",
|
|
|
|
|
+ payload=event_payload,
|
|
|
|
|
+ correlation_id=correlation_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ operation = {
|
|
|
|
|
+ **event_payload,
|
|
|
|
|
+ "gates": gates,
|
|
|
|
|
+ "status": "claimed",
|
|
|
|
|
+ }
|
|
|
|
|
+ return True, operation
|
|
|
|
|
+
|
|
|
|
|
+ def _finish_transition(
|
|
|
|
|
+ self, operation_id, mode, status, result, *, expected_mode
|
|
|
|
|
+ ):
|
|
|
|
|
+ operation_id = _uid(operation_id, "operation_id")
|
|
|
|
|
+ mode = DualRunMode(mode).value
|
|
|
|
|
+ if not isinstance(result, dict):
|
|
|
|
|
+ raise ValueError("transition result must be an object")
|
|
|
|
|
+ with self.engine.begin() as connection:
|
|
|
|
|
+ row = connection.execute(
|
|
|
|
|
+ text(
|
|
|
|
|
+ "SELECT migration_state_id::text "
|
|
|
|
|
+ "FROM public.workflow_cutover_operations "
|
|
|
|
|
+ "WHERE id = CAST(:id AS uuid) AND status = 'claimed' "
|
|
|
|
|
+ "FOR UPDATE"
|
|
|
|
|
+ ),
|
|
|
|
|
+ {"id": operation_id},
|
|
|
|
|
+ ).one_or_none()
|
|
|
|
|
+ if row is None:
|
|
|
|
|
+ raise ValueError("cutover operation is not in claimed state")
|
|
|
|
|
+ state_id = row[0]
|
|
|
|
|
+ definitions = connection.execute(
|
|
|
|
|
+ text(
|
|
|
|
|
+ "SELECT n8n_definition_id, kestra_definition_id "
|
|
|
|
|
+ "FROM public.workflow_migration_states "
|
|
|
|
|
+ "WHERE id = CAST(:state_id AS uuid)"
|
|
|
|
|
+ ),
|
|
|
|
|
+ {"state_id": state_id},
|
|
|
|
|
+ ).one()
|
|
|
|
|
+ connection.execute(
|
|
|
|
|
+ text(
|
|
|
|
|
+ "UPDATE public.workflow_cutover_operations "
|
|
|
|
|
+ "SET status = :status, result = CAST(:result AS jsonb), "
|
|
|
|
|
+ "updated_at = CURRENT_TIMESTAMP "
|
|
|
|
|
+ "WHERE id = CAST(:id AS uuid)"
|
|
|
|
|
+ ),
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": operation_id,
|
|
|
|
|
+ "status": status,
|
|
|
|
|
+ "result": _json(result),
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ updated = connection.execute(
|
|
|
|
|
+ text(
|
|
|
|
|
+ "UPDATE public.workflow_migration_states "
|
|
|
|
|
+ "SET mode = :mode, status = 'stable', "
|
|
|
|
|
+ "updated_at = CURRENT_TIMESTAMP "
|
|
|
|
|
+ "WHERE id = CAST(:state_id AS uuid) "
|
|
|
|
|
+ "AND mode = :expected_mode "
|
|
|
|
|
+ "AND status = 'transition_pending'"
|
|
|
|
|
+ ),
|
|
|
|
|
+ {
|
|
|
|
|
+ "state_id": state_id,
|
|
|
|
|
+ "mode": mode,
|
|
|
|
|
+ "expected_mode": expected_mode,
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ if updated.rowcount != 1:
|
|
|
|
|
+ raise ValueError("workflow migration state changed")
|
|
|
|
|
+ self._sync_engine_bindings(
|
|
|
|
|
+ connection,
|
|
|
|
|
+ mode,
|
|
|
|
|
+ n8n_definition_id=definitions[0],
|
|
|
|
|
+ kestra_definition_id=definitions[1],
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ @staticmethod
|
|
|
|
|
+ def _sync_engine_bindings(
|
|
|
|
|
+ connection,
|
|
|
|
|
+ mode,
|
|
|
|
|
+ *,
|
|
|
|
|
+ n8n_definition_id,
|
|
|
|
|
+ kestra_definition_id,
|
|
|
|
|
+ ):
|
|
|
|
|
+ roles = {
|
|
|
|
|
+ DualRunMode.N8N_PRIMARY.value: {
|
|
|
|
|
+ "n8n": ("primary", "enabled"),
|
|
|
|
|
+ "kestra": ("standby", "disabled"),
|
|
|
|
|
+ },
|
|
|
|
|
+ DualRunMode.N8N_PRIMARY_KESTRA_SHADOW.value: {
|
|
|
|
|
+ "n8n": ("primary", "enabled"),
|
|
|
|
|
+ "kestra": ("shadow", "disabled"),
|
|
|
|
|
+ },
|
|
|
|
|
+ DualRunMode.KESTRA_PRIMARY_N8N_STANDBY.value: {
|
|
|
|
|
+ "n8n": ("standby", "disabled"),
|
|
|
|
|
+ "kestra": ("primary", "enabled"),
|
|
|
|
|
+ },
|
|
|
|
|
+ DualRunMode.KESTRA_PRIMARY.value: {
|
|
|
|
|
+ "n8n": ("archived", "disabled"),
|
|
|
|
|
+ "kestra": ("primary", "enabled"),
|
|
|
|
|
+ },
|
|
|
|
|
+ }[mode]
|
|
|
|
|
+ definitions = {
|
|
|
|
|
+ "n8n": n8n_definition_id,
|
|
|
|
|
+ "kestra": kestra_definition_id,
|
|
|
|
|
+ }
|
|
|
|
|
+ engine_order = (
|
|
|
|
|
+ ("kestra", "n8n")
|
|
|
|
|
+ if mode
|
|
|
|
|
+ in {
|
|
|
|
|
+ DualRunMode.N8N_PRIMARY.value,
|
|
|
|
|
+ DualRunMode.N8N_PRIMARY_KESTRA_SHADOW.value,
|
|
|
|
|
+ }
|
|
|
|
|
+ else ("n8n", "kestra")
|
|
|
|
|
+ )
|
|
|
|
|
+ for engine_type in engine_order:
|
|
|
|
|
+ definition_id = definitions[engine_type]
|
|
|
|
|
+ if definition_id is None:
|
|
|
|
|
+ continue
|
|
|
|
|
+ role, binding_status = roles[engine_type]
|
|
|
|
|
+ connection.execute(
|
|
|
|
|
+ text(
|
|
|
|
|
+ "UPDATE public.workflow_engine_bindings "
|
|
|
|
|
+ "SET role = :role, status = :status, "
|
|
|
|
|
+ "updated_at = CURRENT_TIMESTAMP "
|
|
|
|
|
+ "WHERE engine_type = :engine_type "
|
|
|
|
|
+ "AND engine_definition_id = :definition_id"
|
|
|
|
|
+ ),
|
|
|
|
|
+ {
|
|
|
|
|
+ "role": role,
|
|
|
|
|
+ "status": binding_status,
|
|
|
|
|
+ "engine_type": engine_type,
|
|
|
|
|
+ "definition_id": definition_id,
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def complete_transition(self, operation_id, target_mode, result):
|
|
|
|
|
+ with self.engine.connect() as connection:
|
|
|
|
|
+ source = connection.execute(
|
|
|
|
|
+ text(
|
|
|
|
|
+ "SELECT source_mode "
|
|
|
|
|
+ "FROM public.workflow_cutover_operations "
|
|
|
|
|
+ "WHERE id = CAST(:id AS uuid)"
|
|
|
|
|
+ ),
|
|
|
|
|
+ {"id": _uid(operation_id, "operation_id")},
|
|
|
|
|
+ ).scalar_one_or_none()
|
|
|
|
|
+ if source is None:
|
|
|
|
|
+ raise ValueError("cutover operation was not found")
|
|
|
|
|
+ self._finish_transition(
|
|
|
|
|
+ operation_id,
|
|
|
|
|
+ target_mode,
|
|
|
|
|
+ "succeeded",
|
|
|
|
|
+ result,
|
|
|
|
|
+ expected_mode=source,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def rollback_transition(self, operation_id, source_mode, result):
|
|
|
|
|
+ self._finish_transition(
|
|
|
|
|
+ operation_id,
|
|
|
|
|
+ source_mode,
|
|
|
|
|
+ "rolled_back",
|
|
|
|
|
+ result,
|
|
|
|
|
+ expected_mode=DualRunMode(source_mode).value,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def record_dual_run(self, record):
|
|
|
|
|
+ state_id = record.get("state_id")
|
|
|
|
|
+ if state_id is None:
|
|
|
|
|
+ state = self.get_state(
|
|
|
|
|
+ record.get("dataflow_uid"), record.get("environment")
|
|
|
|
|
+ )
|
|
|
|
|
+ if state is None:
|
|
|
|
|
+ raise ValueError("workflow migration state was not found")
|
|
|
|
|
+ state_id = state["state_id"]
|
|
|
|
|
+ state_id = _uid(state_id, "state_id")
|
|
|
|
|
+ isolation = record.get("isolation") or {}
|
|
|
|
|
+ isolation_mode = (
|
|
|
|
|
+ isolation.get("strategy")
|
|
|
|
|
+ if isinstance(isolation, dict)
|
|
|
|
|
+ else record.get("isolation_mode")
|
|
|
|
|
+ )
|
|
|
|
|
+ dual_run_id = new_governance_uid()
|
|
|
|
|
+ with self.engine.begin() as connection:
|
|
|
|
|
+ connection.execute(
|
|
|
|
|
+ text(
|
|
|
|
|
+ "INSERT INTO public.workflow_dual_runs "
|
|
|
|
|
+ "(id, migration_state_id, input_hash, n8n_execution_id, "
|
|
|
|
|
+ "kestra_execution_id, n8n_status, kestra_status, "
|
|
|
|
|
+ "isolation_mode, formal_engine_changed, correlation_id, "
|
|
|
|
|
+ "safe_metrics) VALUES (CAST(:id AS uuid), "
|
|
|
|
|
+ "CAST(:state_id AS uuid), :input_hash, :n8n_execution_id, "
|
|
|
|
|
+ ":kestra_execution_id, :n8n_status, :kestra_status, "
|
|
|
|
|
+ ":isolation_mode, :formal_engine_changed, "
|
|
|
|
|
+ "CAST(:correlation_id AS uuid), CAST(:safe_metrics AS jsonb))"
|
|
|
|
|
+ ),
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": dual_run_id,
|
|
|
|
|
+ "state_id": state_id,
|
|
|
|
|
+ "input_hash": _required_string(
|
|
|
|
|
+ record.get("input_hash")
|
|
|
|
|
+ or record.get("input_snapshot_hash"),
|
|
|
|
|
+ "input_hash",
|
|
|
|
|
+ 64,
|
|
|
|
|
+ ),
|
|
|
|
|
+ "n8n_execution_id": record.get("n8n_execution_id")
|
|
|
|
|
+ or record.get("primary_execution_id"),
|
|
|
|
|
+ "kestra_execution_id": record.get("kestra_execution_id")
|
|
|
|
|
+ or record.get("shadow_execution_id"),
|
|
|
|
|
+ "n8n_status": _required_string(
|
|
|
|
|
+ record.get("n8n_status")
|
|
|
|
|
+ or record.get("primary_status"),
|
|
|
|
|
+ "n8n_status",
|
|
|
|
|
+ 30,
|
|
|
|
|
+ ),
|
|
|
|
|
+ "kestra_status": _required_string(
|
|
|
|
|
+ record.get("kestra_status")
|
|
|
|
|
+ or record.get("shadow_status"),
|
|
|
|
|
+ "kestra_status",
|
|
|
|
|
+ 30,
|
|
|
|
|
+ ),
|
|
|
|
|
+ "isolation_mode": isolation_mode,
|
|
|
|
|
+ "formal_engine_changed": bool(
|
|
|
|
|
+ record.get("formal_engine_changed", False)
|
|
|
|
|
+ ),
|
|
|
|
|
+ "correlation_id": _uid(
|
|
|
|
|
+ record.get("correlation_id"), "correlation_id"
|
|
|
|
|
+ ),
|
|
|
|
|
+ "safe_metrics": _json(record.get("safe_metrics") or {}),
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ return dual_run_id
|
|
|
|
|
+
|
|
|
|
|
+ def record_reconciliation(self, record):
|
|
|
|
|
+ state_id = _uid(record.get("state_id"), "state_id")
|
|
|
|
|
+ report_id = new_governance_uid()
|
|
|
|
|
+ with self.engine.begin() as connection:
|
|
|
|
|
+ connection.execute(
|
|
|
|
|
+ text(
|
|
|
|
|
+ "INSERT INTO public.workflow_reconciliation_reports "
|
|
|
|
|
+ "(id, migration_state_id, dual_run_id, status, policy, "
|
|
|
|
|
+ "safe_metrics, differences, correlation_id) VALUES ("
|
|
|
|
|
+ "CAST(:id AS uuid), CAST(:state_id AS uuid), "
|
|
|
|
|
+ "CAST(:dual_run_id AS uuid), :status, "
|
|
|
|
|
+ "CAST(:policy AS jsonb), CAST(:safe_metrics AS jsonb), "
|
|
|
|
|
+ "CAST(:differences AS jsonb), "
|
|
|
|
|
+ "CAST(:correlation_id AS uuid))"
|
|
|
|
|
+ ),
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": report_id,
|
|
|
|
|
+ "state_id": state_id,
|
|
|
|
|
+ "dual_run_id": record.get("dual_run_id"),
|
|
|
|
|
+ "status": record.get("status"),
|
|
|
|
|
+ "policy": _json(record.get("policy") or {}),
|
|
|
|
|
+ "safe_metrics": _json(record.get("safe_metrics") or {}),
|
|
|
|
|
+ "differences": _json(record.get("differences") or []),
|
|
|
|
|
+ "correlation_id": _uid(
|
|
|
|
|
+ record.get("correlation_id"), "correlation_id"
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ return report_id
|
|
|
|
|
+
|
|
|
|
|
+ def delete_test_state(self, state_id):
|
|
|
|
|
+ state_id = _uid(state_id, "state_id")
|
|
|
|
|
+ with self.engine.begin() as connection:
|
|
|
|
|
+ operation_ids = [
|
|
|
|
|
+ row[0]
|
|
|
|
|
+ for row in connection.execute(
|
|
|
|
|
+ text(
|
|
|
|
|
+ "SELECT id::text "
|
|
|
|
|
+ "FROM public.workflow_cutover_operations "
|
|
|
|
|
+ "WHERE migration_state_id = CAST(:state_id AS uuid)"
|
|
|
|
|
+ ),
|
|
|
|
|
+ {"state_id": state_id},
|
|
|
|
|
+ )
|
|
|
|
|
+ ]
|
|
|
|
|
+ if operation_ids:
|
|
|
|
|
+ connection.execute(
|
|
|
|
|
+ text(
|
|
|
|
|
+ "DELETE FROM public.outbox_events "
|
|
|
|
|
+ "WHERE aggregate_type = 'workflow_cutover' "
|
|
|
|
|
+ "AND aggregate_id = ANY(:operation_ids)"
|
|
|
|
|
+ ),
|
|
|
|
|
+ {"operation_ids": operation_ids},
|
|
|
|
|
+ )
|
|
|
|
|
+ connection.execute(
|
|
|
|
|
+ text(
|
|
|
|
|
+ "DELETE FROM public.workflow_migration_states "
|
|
|
|
|
+ "WHERE id = CAST(:state_id AS uuid)"
|
|
|
|
|
+ ),
|
|
|
|
|
+ {"state_id": state_id},
|
|
|
|
|
+ )
|