Просмотр исходного кода

Add clustered migration sync for shared disks (SYNCING barrier)

Fabian Fulga 4 месяцев назад
Родитель
Сommit
e7cc804089

+ 2 - 1
coriolis/api-refs/api_samples/transfer/openstack-transfer-create-resp.json

@@ -49,6 +49,7 @@
       }
       }
     },
     },
     "executions": [],
     "executions": [],
-    "scenario": "replica"
+    "scenario": "replica",
+    "clustered": false
   }
   }
 }
 }

+ 1 - 0
coriolis/api-refs/api_samples/transfer/openstack-transfer-get-resp.json

@@ -42,6 +42,7 @@
     "origin_minion_pool_id": null,
     "origin_minion_pool_id": null,
     "destination_minion_pool_id": null,
     "destination_minion_pool_id": null,
     "instance_osmorphing_minion_pool_mappings": {},
     "instance_osmorphing_minion_pool_mappings": {},
+    "clustered": false,
     "executions": [
     "executions": [
       {
       {
         "created_at": "2019-07-11T10:06:47.000000",
         "created_at": "2019-07-11T10:06:47.000000",

+ 2 - 1
coriolis/api-refs/api_samples/transfer/transfer-list-resp.json

@@ -74,7 +74,8 @@
         "instances": {}
         "instances": {}
       },
       },
       "id": "0460aa4d-6b16-4c98-bd56-27ee186e4a22",
       "id": "0460aa4d-6b16-4c98-bd56-27ee186e4a22",
-      "scenario": "replica"
+      "scenario": "replica",
+      "clustered": false
     }
     }
   ]
   ]
 }
 }

+ 2 - 1
coriolis/api-refs/api_samples/transfer/transfer-update-resp.json

@@ -133,7 +133,8 @@
           "ubuntu-xenial": "echo 'anything you need'"
           "ubuntu-xenial": "echo 'anything you need'"
         }
         }
       },
       },
-      "scenario": "replica"
+      "scenario": "replica",
+      "clustered": false
     }
     }
   }
   }
 }
 }

+ 9 - 0
coriolis/api-refs/source/parameters.yaml

@@ -130,6 +130,15 @@ connection_info_schema:
   in: body
   in: body
   type: object
   type: object
   required: false
   required: false
+clustered:
+  description: |
+    Present on transfer responses. ``true`` when more than one instance is
+    listed (multi-instance scheduling: sync barriers and shared-disk
+    coordination). Set by the server at creation from ``instances``; not
+    accepted on create.
+  in: body
+  type: boolean
+  required: false
 deployment_cancel:
 deployment_cancel:
   description: |
   description: |
     Object containing information about the type of deployment cancellation.
     Object containing information about the type of deployment cancellation.

+ 3 - 0
coriolis/api-refs/source/transfer.inc

@@ -51,6 +51,7 @@ Response
   - instance_osmorphing_minion_pool_mappings : instance_osmorphing_minion_pool_mappings
   - instance_osmorphing_minion_pool_mappings : instance_osmorphing_minion_pool_mappings
   - user_scripts : user_scripts
   - user_scripts : user_scripts
   - scenario: scenario_type
   - scenario: scenario_type
+  - clustered : clustered
 
 
 **Example of Transfer List Response**
 **Example of Transfer List Response**
 
 
@@ -111,6 +112,7 @@ Response
   - instance_osmorphing_minion_pool_mappings : instance_osmorphing_minion_pool_mappings
   - instance_osmorphing_minion_pool_mappings : instance_osmorphing_minion_pool_mappings
   - user_scripts : user_scripts
   - user_scripts : user_scripts
   - scenario: scenario_type
   - scenario: scenario_type
+  - clustered : clustered
 
 
 **Example of Transfer Show Response**
 **Example of Transfer Show Response**
 
 
@@ -183,6 +185,7 @@ Response
   - instance_osmorphing_minion_pool_mappings : instance_osmorphing_minion_pool_mappings
   - instance_osmorphing_minion_pool_mappings : instance_osmorphing_minion_pool_mappings
   - user_scripts : user_scripts
   - user_scripts : user_scripts
   - scenario: scenario_type
   - scenario: scenario_type
+  - clustered : clustered
 
 
 **Example of Transfer Create Response**
 **Example of Transfer Create Response**
 
 

+ 256 - 110
coriolis/conductor/rpc/server.py

@@ -794,10 +794,10 @@ class ConductorServerEndpoint(object):
         """
         """
         all_instances_in_tasks = {
         all_instances_in_tasks = {
             t.instance for t in execution.tasks}
             t.instance for t in execution.tasks}
-        instances_tasks_mapping = {
-            instance: [
-                t for t in execution.tasks if t.instance == instance]
-            for instance in all_instances_in_tasks}
+        if not all_instances_in_tasks:
+            return
+        if initial_task_info is None:
+            initial_task_info = {}
 
 
         def _check_task_cls_param_requirements(task, instance_task_info_keys):
         def _check_task_cls_param_requirements(task, instance_task_info_keys):
             task_cls = tasks_factory.get_task_runner_class(task.task_type)
             task_cls = tasks_factory.get_task_runner_class(task.task_type)
@@ -813,108 +813,85 @@ class ConductorServerEndpoint(object):
                         missing_params))
                         missing_params))
             return task_cls.get_returned_task_info_properties()
             return task_cls.get_returned_task_info_properties()
 
 
-        for instance, instance_tasks in instances_tasks_mapping.items():
-            task_info_keys = set(initial_task_info.get(
-                instance, {}).keys())
-            # mapping between the ID and associated object of processed tasks:
-            processed_tasks = {}
-            tasks_to_process = {
-                t.id: t for t in instance_tasks}
-            while tasks_to_process:
-                queued_tasks = []
-                # gather all tasks which will be queued to run in parallel:
-                for task in tasks_to_process.values():
-                    if task.status in (
-                            constants.TASK_STATUS_SCHEDULED,
-                            constants.TASK_STATUS_ON_ERROR_ONLY):
-                        if not task.depends_on:
-                            queued_tasks.append(task)
-                        else:
-                            missing_deps = [
-                                dep_id
-                                for dep_id in task.depends_on
-                                if dep_id not in tasks_to_process and (
-                                    dep_id not in processed_tasks)]
-                            if missing_deps:
-                                raise exception.TaskDependencyException(
-                                    "Task '%s' (type '%s') for instance '%s' "
-                                    "has non-existent tasks referenced as "
-                                    "dependencies: %s" % (
-                                        task.id, task.task_type,
-                                        instance, missing_deps))
-                            if all(
-                                    [dep_id in processed_tasks
-                                     for dep_id in task.depends_on]):
-                                queued_tasks.append(task)
-                    else:
-                        raise exception.InvalidTaskState(
-                            "Invalid initial state '%s' for task '%s' "
-                            "of type '%s'." % (
-                                task.status, task.id, task.task_type))
-
-                # check if nothing was left queued:
-                if not queued_tasks:
-                    remaining_tasks_deps_map = {
-                        (tid, t.task_type): t.depends_on
-                        for tid, t in tasks_to_process.items()}
-                    processed_tasks_type_map = {
-                        tid: t.task_type
-                        for tid, t in processed_tasks.items()}
-                    raise exception.ExecutionDeadlockException(
-                        "Execution '%s' (type '%s') is bound to be deadlocked:"
-                        " there are leftover tasks for instance '%s' which "
-                        "will never get queued. Already processed tasks are: "
-                        "%s. Tasks left: %s" % (
-                            execution.id, execution.type, instance,
-                            processed_tasks_type_map, remaining_tasks_deps_map
-                        ))
-
-                # mapping for task_info fields modified by each task:
-                modified_fields_by_queued_tasks = {}
-                # check that each task has what it needs and
-                # register what they return/modify:
-                for task in queued_tasks:
-                    for new_field in _check_task_cls_param_requirements(
-                            task, task_info_keys):
-                        if new_field not in modified_fields_by_queued_tasks:
-                            modified_fields_by_queued_tasks[new_field] = [
-                                task]
-                        else:
-                            modified_fields_by_queued_tasks[new_field].append(
-                                task)
-
-                # check if any queued tasks would manipulate the same fields:
-                conflicting_fields = {
-                    new_field: [t.task_type for t in tasks]
-                    for new_field, tasks in (
-                        modified_fields_by_queued_tasks.items())
-                    if len(tasks) > 1}
-                if conflicting_fields:
-                    raise exception.TaskFieldsConflict(
-                        "There are fields which will encounter a state "
-                        "conflict following the parallelized execution of "
-                        "tasks for execution '%s' (type '%s') for instance "
-                        "'%s'. Conflicting fields and tasks will be: : %s" % (
-                            execution.id, execution.type, instance,
-                            conflicting_fields))
-
-                # register queued tasks as processed before continuing:
-                for task in queued_tasks:
-                    processed_tasks[task.id] = task
-                    tasks_to_process.pop(task.id)
-                # update current state fields at this point:
-                task_info_keys = task_info_keys.union(set(
-                    modified_fields_by_queued_tasks.keys()))
-                LOG.debug(
-                    "Successfully processed following tasks for instance '%s' "
-                    "for execution %s (type '%s') for any state conflict "
-                    "checks: %s", instance, execution.id, execution.type,
-                    [(t.id, t.task_type) for t in queued_tasks])
+        all_tasks_by_id = {t.id: t for t in execution.tasks}
+        task_info_by_instance = {
+            inst: set(initial_task_info.get(inst, {}).keys())
+            for inst in all_instances_in_tasks}
+
+        tasks_to_process = {t.id: t for t in execution.tasks}
+        if not tasks_to_process:
+            return
+        processed_tasks = set()
+        while tasks_to_process:
+            queued_tasks = []
+            for task in tasks_to_process.values():
+                if task.status not in (
+                        constants.TASK_STATUS_SCHEDULED,
+                        constants.TASK_STATUS_ON_ERROR_ONLY):
+                    raise exception.InvalidTaskState(
+                        "Invalid initial state '%s' for task '%s' "
+                        "of type '%s'." % (
+                            task.status, task.id, task.task_type))
+                if not task.depends_on:
+                    queued_tasks.append(task)
+                else:
+                    dep_ids = list(task.depends_on)
+                    missing = [
+                        dep_id for dep_id in dep_ids
+                        if dep_id not in all_tasks_by_id]
+                    if missing:
+                        raise exception.TaskDependencyException(
+                            "Task '%s' (type '%s') for instance '%s' "
+                            "has non-existent tasks referenced as "
+                            "dependencies: %s" % (
+                                task.id, task.task_type, task.instance,
+                                missing))
+                    if all(
+                            dep_id in processed_tasks
+                            for dep_id in dep_ids):
+                        queued_tasks.append(task)
+
+            if not queued_tasks:
+                raise exception.ExecutionDeadlockException(
+                    "Execution '%s' (type '%s') is bound to be deadlocked: "
+                    "cannot schedule a next wave. Remaining: %s. "
+                    "Processed: %s" % (
+                        execution.id, execution.type,
+                        {t.id: t.depends_on
+                         for t in tasks_to_process.values()},
+                        list(processed_tasks)))
+
+            new_fields_by_task = {}
+            for task in queued_tasks:
+                new_fields_by_task[task] = _check_task_cls_param_requirements(
+                    task, task_info_by_instance[task.instance])
+            modified_by_inst_field = {}
+            for task, new_fields in new_fields_by_task.items():
+                for new_field in new_fields:
+                    key = (task.instance, new_field)
+                    modified_by_inst_field.setdefault(key, []).append(task)
+            conflicts = {
+                (inst, field): [t.task_type for t in tlist]
+                for (inst, field), tlist in modified_by_inst_field.items()
+                if len(tlist) > 1}
+            if conflicts:
+                raise exception.TaskFieldsConflict(
+                    "There are fields which will encounter a state "
+                    "conflict for execution '%s' (type '%s') (instance+field) "
+                    "and tasks: %s" % (
+                        execution.id, execution.type, conflicts))
+
+            for _task, new_fields in new_fields_by_task.items():
+                for new_field in new_fields:
+                    task_info_by_instance[_task.instance].add(new_field)
+            for task in queued_tasks:
+                processed_tasks.add(task.id)
+                tasks_to_process.pop(task.id, None)
             LOG.debug(
             LOG.debug(
-                "Successfully checked all tasks for instance '%s' as part of "
-                "execution '%s' (type '%s') for any state conflicts: %s",
-                instance, execution.id, execution.type,
-                [(t.id, t.task_type) for t in instance_tasks])
+                "Sanity check wave for execution '%s': %s",
+                execution.id,
+                [(t.id, t.task_type, t.instance) for t in queued_tasks])
+
         LOG.debug(
         LOG.debug(
             "Successfully checked all tasks for execution '%s' (type '%s') "
             "Successfully checked all tasks for execution '%s' (type '%s') "
             "for ordering or state conflicts.",
             "for ordering or state conflicts.",
@@ -1329,6 +1306,7 @@ class ConductorServerEndpoint(object):
                                   network_map, storage_mappings, notes=None,
                                   network_map, storage_mappings, notes=None,
                                   user_scripts=None, clone_disks=True,
                                   user_scripts=None, clone_disks=True,
                                   skip_os_morphing=False):
                                   skip_os_morphing=False):
+        clustered = len(instances) > 1
         supported_scenarios = [
         supported_scenarios = [
             constants.TRANSFER_SCENARIO_REPLICA,
             constants.TRANSFER_SCENARIO_REPLICA,
             constants.TRANSFER_SCENARIO_LIVE_MIGRATION]
             constants.TRANSFER_SCENARIO_LIVE_MIGRATION]
@@ -1365,6 +1343,7 @@ class ConductorServerEndpoint(object):
         transfer.user_scripts = user_scripts or {}
         transfer.user_scripts = user_scripts or {}
         transfer.clone_disks = clone_disks
         transfer.clone_disks = clone_disks
         transfer.skip_os_morphing = skip_os_morphing
         transfer.skip_os_morphing = skip_os_morphing
+        transfer.clustered = clustered
 
 
         self._check_minion_pools_for_action(ctxt, transfer)
         self._check_minion_pools_for_action(ctxt, transfer)
 
 
@@ -1783,6 +1762,7 @@ class ConductorServerEndpoint(object):
         deployment.user_scripts = user_scripts
         deployment.user_scripts = user_scripts
         deployment.clone_disks = clone_disks
         deployment.clone_disks = clone_disks
         deployment.skip_os_morphing = skip_os_morphing
         deployment.skip_os_morphing = skip_os_morphing
+        deployment.clustered = bool(getattr(transfer, 'clustered', False))
         deployment.deployer_id = wait_for_execution
         deployment.deployer_id = wait_for_execution
         deployment.trust_id = trust_id
         deployment.trust_id = trust_id
         deployment.last_execution_status = init_status
         deployment.last_execution_status = init_status
@@ -2130,13 +2110,15 @@ class ConductorServerEndpoint(object):
                     exception_details=(
                     exception_details=(
                         "This task was unscheduled during the cancellation "
                         "This task was unscheduled during the cancellation "
                         "of the parent tasks execution."))
                         "of the parent tasks execution."))
+            # NOTE: SYNCING covers tasks waiting on a clustered
+            # barrier; there is no running worker to cancel, so we
+            # unschedule like PENDING/STARTING. (Peers stuck in
+            # SYNCING when another instance fails are handled in
+            # _abort_peer_sync_barrier_tasks_on_error, not here.)
             elif task.status in (
             elif task.status in (
                     constants.TASK_STATUS_PENDING,
                     constants.TASK_STATUS_PENDING,
-                    constants.TASK_STATUS_STARTING):
-                # any PENDING/STARTING tasks means that they did not have a
-                # host assigned to them yet, and presuming the host does not
-                # start executing the task until it marks itself as the runner,
-                # we can just mark the task as unscheduled:
+                    constants.TASK_STATUS_STARTING,
+                    constants.TASK_STATUS_SYNCING):
                 LOG.debug(
                 LOG.debug(
                     "Setting currently '%s' task '%s' to '%s' as part of the "
                     "Setting currently '%s' task '%s' to '%s' as part of the "
                     "cancellation of execution '%s'",
                     "cancellation of execution '%s'",
@@ -2226,6 +2208,30 @@ class ConductorServerEndpoint(object):
                 "No new tasks were started for execution '%s' following "
                 "No new tasks were started for execution '%s' following "
                 "state advancement after cancellation.", execution.id)
                 "state advancement after cancellation.", execution.id)
 
 
+    def _abort_peer_sync_barrier_tasks_on_error(
+            self, ctxt, execution, errored_task, error_message):
+        """Mark peer tasks stuck in SYNCING on the same barrier as failed."""
+        if errored_task.task_type not in constants.TASK_TYPES_TO_SYNC:
+            return
+        action = db_api.get_action(
+            ctxt, execution.action_id, include_task_info=True)
+        if not bool(getattr(action, "clustered", False)):
+            return
+        execution = db_api.get_tasks_execution(ctxt, execution.id)
+        for peer in execution.tasks:
+            if peer.id == errored_task.id:
+                continue
+            if peer.status != constants.TASK_STATUS_SYNCING:
+                continue
+            if peer.task_type != errored_task.task_type:
+                continue
+            db_api.set_task_status(
+                ctxt, peer.id, constants.TASK_STATUS_ERROR,
+                exception_details=(
+                    "Aborted: peer task '%s' failed during clustered "
+                    "execution. Original error: %s" % (
+                        errored_task.id, error_message)))
+
     def _update_reservation_fulfillment_for_execution(self, ctxt, execution):
     def _update_reservation_fulfillment_for_execution(self, ctxt, execution):
         """ Updates the reservation fulfillment status for the parent
         """ Updates the reservation fulfillment status for the parent
         transfer action of the given execution based on its type.
         transfer action of the given execution based on its type.
@@ -2827,6 +2833,137 @@ class ConductorServerEndpoint(object):
             self._update_transfer_volumes_info(
             self._update_transfer_volumes_info(
                 ctxt, transfer_id, instance, updated_task_info)
                 ctxt, transfer_id, instance, updated_task_info)
 
 
+    def _handle_task_sync_barrier(self, ctxt, task, execution, action):
+        """Implements a cross-instance sync barrier for clustered actions.
+
+        If the completed task's type is in TASK_TYPES_TO_SYNC and the
+        action is clustered, the task is set to SYNCING instead of
+        remaining COMPLETED. Once all instances' tasks of the same type
+        reach SYNCING, _handle_synced_tasks runs the type-specific sync
+        logic, then all tasks are set to COMPLETED and their execution
+        states are advanced.
+
+        Returns True if the barrier was activated (caller should return
+        early), False otherwise.
+        """
+        if task.task_type not in constants.TASK_TYPES_TO_SYNC:
+            return False
+        if not bool(getattr(action, "clustered", False)):
+            return False
+
+        db_api.set_task_status(
+            ctxt, task.id, constants.TASK_STATUS_SYNCING)
+
+        peer_tasks = [
+            t for t in execution.tasks
+            if t.task_type == task.task_type and t.id != task.id]
+        all_syncing = all(
+            t.status == constants.TASK_STATUS_SYNCING for t in peer_tasks)
+        if not all_syncing:
+            LOG.info(
+                "Task '%s' (type '%s') for instance '%s' is now SYNCING. "
+                "Waiting for peer tasks of other instances to reach SYNCING.",
+                task.id, task.task_type, task.instance)
+            return True
+
+        LOG.info(
+            "All tasks of type '%s' across all instances have reached "
+            "SYNCING for execution '%s'. Running sync handler.",
+            task.task_type, execution.id)
+
+        action = db_api.get_action(
+            ctxt, execution.action_id, include_task_info=True)
+        self._handle_synced_tasks(ctxt, task.task_type, execution, action)
+
+        # NOTE: the sync handler may have updated the action info in the
+        # DB through separate sessions (detaching the above `action` ORM
+        # object in the process), so re-fetch it to get both a session-bound
+        # object and the freshest task info for the post-task actions below:
+        action = db_api.get_action(
+            ctxt, execution.action_id, include_task_info=True)
+
+        synced_tasks = [task] + peer_tasks
+        for synced_task in synced_tasks:
+            db_api.set_task_status(
+                ctxt, synced_task.id, constants.TASK_STATUS_COMPLETED)
+
+        for synced_task in synced_tasks:
+            self._handle_post_task_actions(
+                ctxt, synced_task, execution,
+                action.info.get(synced_task.instance, {}))
+            self._advance_execution_state(
+                ctxt, execution, instance=synced_task.instance, requery=True)
+
+        return True
+
+    def _assign_clustered_export_disk_owners(self, ctxt, execution, action):
+        """Assign an ``owner`` to every disk in each instance's export_info.
+
+        The owner of a disk is the first instance (in ``action.instances``
+        order) whose ``export_info`` reports that disk ``id``. For regular
+        (non-shared) disks the owner is the only instance exposing them;
+        for shared disks (same ``id`` reported by multiple instances),
+        only the owner's DEPLOY_TRANSFER_DISKS task will create a destination
+        volume and replicate data into it, while the other instances' tasks
+        will only record a placeholder ``volumes_info`` entry with
+        ``replicate_disk_data`` set to False (handled provider-side).
+        """
+        # NOTE: snapshot everything needed from the `action` ORM object
+        # before any DB writes below:
+        # update_transfer_action_info_for_instance runs in a separate
+        # session, which leaves the passed-in `action` object detached from
+        # its own session, so any lazy attribute access on it afterwards
+        # would raise a DetachedInstanceError.
+        action_id = execution.action_id
+        instances = list(action.instances)
+        action_info = action.info
+
+        disk_owners_map = {}
+        for instance_id in instances:
+            export_info = action_info.get(instance_id, {}).get("export_info")
+            if not export_info:
+                LOG.warning(
+                    "No export_info found for instance '%s' of action '%s' "
+                    "while assigning clustered disk owners. Skipping it.",
+                    instance_id, action_id)
+                continue
+            disks = export_info.get("devices", {}).get("disks", [])
+            updated = False
+            for disk in disks:
+                disk_id = disk.get("id")
+                if not disk_id:
+                    continue
+                if disk_id not in disk_owners_map:
+                    disk_owners_map[disk_id] = instance_id
+                owner = disk_owners_map[disk_id]
+                if disk.get("owner") != owner:
+                    disk["owner"] = owner
+                    updated = True
+            if updated:
+                db_api.update_transfer_action_info_for_instance(
+                    ctxt, action_id, instance_id,
+                    {"export_info": export_info})
+                LOG.info(
+                    "Assigned disk owners in export_info of instance '%s' "
+                    "for clustered action '%s': %s",
+                    instance_id, action_id, {
+                        d.get("id"): d.get("owner") for d in disks})
+
+    def _handle_synced_tasks(self, ctxt, task_type, execution, action):
+        """Runs type-specific logic after all instances reach a sync barrier.
+
+        Called once all tasks of a given type across all instances have
+        reached SYNCING. The handler can inspect and modify action info
+        (e.g. export_info/volumes_info) for all instances.
+        """
+        if task_type == constants.TASK_TYPE_GET_INSTANCE_INFO:
+            self._assign_clustered_export_disk_owners(ctxt, execution, action)
+        elif task_type == constants.TASK_TYPE_SHUTDOWN_INSTANCE:
+            LOG.info(
+                "All instances have been shut down for clustered execution "
+                "'%s'. No additional sync handling required for shutdown.",
+                execution.id)
+
     def _handle_post_task_actions(self, ctxt, task, execution, task_info):
     def _handle_post_task_actions(self, ctxt, task, execution, task_info):
         task_type = task.task_type
         task_type = task.task_type
 
 
@@ -3143,6 +3280,11 @@ class ConductorServerEndpoint(object):
 
 
             # NOTE: refresh the execution just in case:
             # NOTE: refresh the execution just in case:
             execution = db_api.get_tasks_execution(ctxt, task.execution_id)
             execution = db_api.get_tasks_execution(ctxt, task.execution_id)
+
+            if self._handle_task_sync_barrier(
+                    ctxt, task, execution, action):
+                return
+
             self._handle_post_task_actions(
             self._handle_post_task_actions(
                 ctxt, task, execution, updated_task_info)
                 ctxt, task, execution, updated_task_info)
 
 
@@ -3373,8 +3515,12 @@ class ConductorServerEndpoint(object):
                         "Some tasks are running in parallel with the "
                         "Some tasks are running in parallel with the "
                         "OSMorphing task, a debug setup cannot be safely "
                         "OSMorphing task, a debug setup cannot be safely "
                         "achieved. Proceeding with cleanup tasks as usual.")
                         "achieved. Proceeding with cleanup tasks as usual.")
+                    self._abort_peer_sync_barrier_tasks_on_error(
+                        ctxt, execution, task, exception_details)
                     self._cancel_tasks_execution(ctxt, execution)
                     self._cancel_tasks_execution(ctxt, execution)
             else:
             else:
+                self._abort_peer_sync_barrier_tasks_on_error(
+                    ctxt, execution, task, exception_details)
                 self._cancel_tasks_execution(ctxt, execution)
                 self._cancel_tasks_execution(ctxt, execution)
 
 
     @task_synchronized
     @task_synchronized

+ 7 - 0
coriolis/constants.py

@@ -55,11 +55,13 @@ TASK_STATUS_CANCELED_FOR_DEBUGGING = "CANCELED_FOR_DEBUGGING"
 TASK_STATUS_CANCELED_FROM_DEADLOCK = "STRANDED_AFTER_DEADLOCK"
 TASK_STATUS_CANCELED_FROM_DEADLOCK = "STRANDED_AFTER_DEADLOCK"
 TASK_STATUS_ON_ERROR_ONLY = "EXECUTE_ON_ERROR_ONLY"
 TASK_STATUS_ON_ERROR_ONLY = "EXECUTE_ON_ERROR_ONLY"
 TASK_STATUS_FAILED_TO_SCHEDULE = "FAILED_TO_SCHEDULE"
 TASK_STATUS_FAILED_TO_SCHEDULE = "FAILED_TO_SCHEDULE"
+TASK_STATUS_SYNCING = "SYNCING"
 
 
 ACTIVE_TASK_STATUSES = [
 ACTIVE_TASK_STATUSES = [
     TASK_STATUS_PENDING,
     TASK_STATUS_PENDING,
     TASK_STATUS_STARTING,
     TASK_STATUS_STARTING,
     TASK_STATUS_RUNNING,
     TASK_STATUS_RUNNING,
+    TASK_STATUS_SYNCING,
     TASK_STATUS_CANCELLING,
     TASK_STATUS_CANCELLING,
     TASK_STATUS_CANCELLING_AFTER_COMPLETION
     TASK_STATUS_CANCELLING_AFTER_COMPLETION
 ]
 ]
@@ -182,6 +184,10 @@ TASK_TYPE_POWER_OFF_SOURCE_MINION = "POWER_OFF_SOURCE_MINION"
 TASK_TYPE_POWER_ON_DESTINATION_MINION = "POWER_ON_DESTINATION_MINION"
 TASK_TYPE_POWER_ON_DESTINATION_MINION = "POWER_ON_DESTINATION_MINION"
 TASK_TYPE_POWER_OFF_DESTINATION_MINION = "POWER_OFF_DESTINATION_MINION"
 TASK_TYPE_POWER_OFF_DESTINATION_MINION = "POWER_OFF_DESTINATION_MINION"
 
 
+TASK_TYPES_TO_SYNC = [
+    TASK_TYPE_GET_INSTANCE_INFO,
+    TASK_TYPE_SHUTDOWN_INSTANCE,
+]
 
 
 MINION_POOL_OPERATIONS_TASKS = [
 MINION_POOL_OPERATIONS_TASKS = [
     TASK_TYPE_VALIDATE_SOURCE_MINION_POOL_OPTIONS,
     TASK_TYPE_VALIDATE_SOURCE_MINION_POOL_OPTIONS,
@@ -240,6 +246,7 @@ DISK_FORMAT_QCOW = "qcow"
 DISK_FORMAT_QCOW2 = 'qcow2'
 DISK_FORMAT_QCOW2 = 'qcow2'
 DISK_FORMAT_VHD = 'vhd'
 DISK_FORMAT_VHD = 'vhd'
 DISK_FORMAT_VHDX = 'vhdx'
 DISK_FORMAT_VHDX = 'vhdx'
+VOLUME_INFO_REPLICATE_DISK_DATA = "replicate_disk_data"
 
 
 DISK_ALLOCATION_TYPE_STATIC = "static"
 DISK_ALLOCATION_TYPE_STATIC = "static"
 DISK_ALLOCATION_TYPE_DYNAMIC = "dynamic"
 DISK_ALLOCATION_TYPE_DYNAMIC = "dynamic"

+ 20 - 0
coriolis/db/sqlalchemy/migrate_repo/versions/024_add_clustered_to_base_transfer_action.py

@@ -0,0 +1,20 @@
+# Copyright 2026 Cloudbase Solutions Srl
+# All Rights Reserved.
+
+import sqlalchemy
+
+
+def upgrade(migrate_engine):
+    meta = sqlalchemy.MetaData()
+    meta.bind = migrate_engine
+
+    base_transfer = sqlalchemy.Table(
+        'base_transfer_action', meta, autoload=True)
+    if 'clustered' in base_transfer.c:
+        return
+    # server_default so existing rows get a value when the column is added
+    # (MySQL stores booleans as TINYINT).
+    clustered = sqlalchemy.Column(
+        'clustered', sqlalchemy.Boolean, nullable=False,
+        server_default=sqlalchemy.text('0'))
+    base_transfer.create_column(clustered)

+ 5 - 0
coriolis/db/sqlalchemy/models.py

@@ -285,6 +285,10 @@ class BaseTransferAction(BASE, models.TimestampMixin, models.ModelBase,
         sqlalchemy.Boolean, nullable=False, default=True)
         sqlalchemy.Boolean, nullable=False, default=True)
     skip_os_morphing = sqlalchemy.Column(
     skip_os_morphing = sqlalchemy.Column(
         sqlalchemy.Boolean, nullable=False, default=False)
         sqlalchemy.Boolean, nullable=False, default=False)
+    # Multi-instance transfer: enables cross-instance sync barriers and
+    # shared-disk handling. Must be set on INSERT (MySQL NOT NULL).
+    clustered = sqlalchemy.Column(
+        sqlalchemy.Boolean, nullable=False, default=False)
 
 
     __mapper_args__ = {
     __mapper_args__ = {
         'polymorphic_identity': 'base_transfer_action',
         'polymorphic_identity': 'base_transfer_action',
@@ -320,6 +324,7 @@ class BaseTransferAction(BASE, models.TimestampMixin, models.ModelBase,
             "user_scripts": self.user_scripts,
             "user_scripts": self.user_scripts,
             "clone_disks": self.clone_disks,
             "clone_disks": self.clone_disks,
             "skip_os_morphing": self.skip_os_morphing,
             "skip_os_morphing": self.skip_os_morphing,
+            "clustered": bool(self.clustered),
         }
         }
         if include_executions:
         if include_executions:
             for ex in self.executions:
             for ex in self.executions:

+ 1 - 1
coriolis/schemas/disk_sync_resources_info_schema.json

@@ -15,7 +15,7 @@
           },
           },
           "volume_dev": {
           "volume_dev": {
             "type": "string",
             "type": "string",
-            "description": "String device path (ex: /dev/sdc) from within the temporary minion VM where the disk was attached."
+            "description": "Guest minion device path (e.g. /dev/disk/by-id/...) when the volume is attached; use \"\" for rows that do not represent a transferred block dev yet (e.g. shared-disk non-owners)."
           }
           }
         },
         },
         "required": ["disk_id", "volume_dev"],
         "required": ["disk_id", "volume_dev"],

+ 9 - 0
coriolis/schemas/vm_export_info_schema.json

@@ -125,9 +125,18 @@
                   "type": "string"
                   "type": "string"
                 },
                 },
                 "description": "Identifiers which previous versions of the source provider may have reported as this disk's 'id' (e.g. the VMware provider moving from per-VM device keys to virtual disk UUIDs). Used to migrate the 'disk_id' fields of pre-existing replicas' volumes_info to the current identifier."
                 "description": "Identifiers which previous versions of the source provider may have reported as this disk's 'id' (e.g. the VMware provider moving from per-VM device keys to virtual disk UUIDs). Used to migrate the 'disk_id' fields of pre-existing replicas' volumes_info to the current identifier."
+              },
+              "shareable": {
+                "type": "boolean",
+                "description": "Whether the disk is shared (multi-writer) and can be attached to multiple VMs simultaneously."
+              },
+              "owner": {
+                "type": "string",
+                "description": "The identifier of the instance owning this disk within a clustered (multi-instance) transfer. Set by the conductor after all GET_INSTANCE_INFO tasks have synced. For shared disks, only the owner instance's tasks create/replicate the corresponding destination volume; other instances only reference it."
               }
               }
             },
             },
             "required": [
             "required": [
+              "id",
               "size_bytes"
               "size_bytes"
             ]
             ]
           }
           }

+ 5 - 0
coriolis/tasks/replica_tasks.py

@@ -298,6 +298,11 @@ class ReplicateDisksTask(base.TaskRunner):
         source_environment = task_info['source_environment']
         source_environment = task_info['source_environment']
 
 
         source_resources = task_info.get('source_resources', {})
         source_resources = task_info.get('source_resources', {})
+        # NOTE: the full volumes_info list is passed to the provider,
+        # including any entries with 'replicate_disk_data' set to False
+        # (e.g. shared disks of clustered transfers whose data is
+        # replicated by their owner instance's task). It is up to each
+        # provider to skip replicating data for such volumes.
         volumes_info = provider.replicate_disks(
         volumes_info = provider.replicate_disks(
             ctxt, connection_info, source_environment, instance,
             ctxt, connection_info, source_environment, instance,
             source_resources, migr_source_conn_info, migr_target_conn_info,
             source_resources, migr_source_conn_info, migr_target_conn_info,

+ 1 - 0
coriolis/tests/conductor/rpc/test_server.py

@@ -1307,6 +1307,7 @@ class ConductorServerEndpointTestCase(test_base.CoriolisBaseTestCase):
             if has_origin_minion_pool else None,
             if has_origin_minion_pool else None,
             destination_minion_pool_id=mock.sentinel.destination_minion_pool_id
             destination_minion_pool_id=mock.sentinel.destination_minion_pool_id
             if has_target_minion_pool else None,
             if has_target_minion_pool else None,
+            clustered=False,
         )
         )
         mock_get_transfer.return_value = mock_transfer
         mock_get_transfer.return_value = mock_transfer
 
 

+ 2 - 0
coriolis/tests/db/sqlalchemy/test_models.py

@@ -283,6 +283,7 @@ class BaseTransferActionTestCase(test_base.CoriolisBaseTestCase):
         transfer.info = mock.sentinel.info
         transfer.info = mock.sentinel.info
         transfer.clone_disks = True
         transfer.clone_disks = True
         transfer.skip_os_morphing = False
         transfer.skip_os_morphing = False
+        transfer.clustered = False
         expected_result = {
         expected_result = {
             "base_id": mock.sentinel.base_id,
             "base_id": mock.sentinel.base_id,
             "user_id": mock.sentinel.user_id,
             "user_id": mock.sentinel.user_id,
@@ -314,6 +315,7 @@ class BaseTransferActionTestCase(test_base.CoriolisBaseTestCase):
             "info": mock.sentinel.info,
             "info": mock.sentinel.info,
             "clone_disks": True,
             "clone_disks": True,
             "skip_os_morphing": False,
             "skip_os_morphing": False,
+            "clustered": False,
         }
         }
 
 
         result = transfer.to_dict()
         result = transfer.to_dict()

+ 2 - 0
coriolis/tests/tasks/test_replica_tasks.py

@@ -146,6 +146,8 @@ class ReplicateDisksTaskTestCase(test_base.CoriolisBaseTestCase):
         task_info.get.side_effect = [
         task_info.get.side_effect = [
             task_info['incremental'], task_info['source_resources']]
             task_info['incremental'], task_info['source_resources']]
         prov_fun = mock_get_provider.return_value.replicate_disks
         prov_fun = mock_get_provider.return_value.replicate_disks
+        mock_get_vol_info.return_value = [{"disk_id": "disk_id1"}]
+        prov_fun.return_value = [{"disk_id": "disk_id1"}]
         expected_result = {"volumes_info": mock_check_vol_info.return_value}
         expected_result = {"volumes_info": mock_check_vol_info.return_value}
         expected_validation_calls = [
         expected_validation_calls = [
             mock.call.mock_validate_value(
             mock.call.mock_validate_value(