Repository: localstack/localstack

Issue:
Enhancement request: Support AWS SQS's newly announced "SQS Dead-Letter Queue Redrive via AWS SDK or CLI"
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Enhancement description

On June 8, 2023, Amazon just announced `support for dead-letter queue redrive via AWS SDK or CLI`

The SDK introduces three new 'tasks' that can be performed against an SQS dead letter queue:
* `StartMessageMoveTask`
* `CancelMessageMoveTask`
* `ListMessageMoveTasks`

LocalStack's SQS implementation should be updated to support these new DLQ redrive operations.

-------------------------

Not surprisingly when I attempting to do this the day after announcement, LocalStack doesn't yet support these operations. Using the latest LocalStack docker container tag of `2.1.0` and the latest Java AWS SDK version `2.20.82`, I attempted to perform one of these new `StartMessageMoveTask` against LocalStack using the following:
```java
StartMessageMoveTaskResponse moveTaskResponse =
      sqs.startMessageMoveTask(builder -> builder.sourceArn(dlqArn).destinationArn(qArn));
```
The container produced the following error message:
```
2023-06-09T17:06:50.098 ERROR --- [   asgi_gw_0] l.aws.handlers.logging     : exception during call chain: Operation detection failed.Operation StartMessageMoveTask could not be found for service ServiceModel(sqs).
2023-06-09T17:06:50.099347340Z 2023-06-09T17:06:50.099  INFO --- [   asgi_gw_0] localstack.request.http    : POST / => 500
```




### 🧑‍💻 Implementation

_No response_

### Anything else?

Relevant AWS announcements and API specs:
* https://aws.amazon.com/blogs/aws/a-new-set-of-apis-for-amazon-sqs-dead-letter-queue-redrive/
* https://aws.amazon.com/about-aws/whats-new/2023/06/amazon-sqs-dead-letter-queue-redrive-aws-sdk-cli/
* https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-configure-dead-letter-queue-redrive.html


Discussion:
Welcome to LocalStack! Thanks for reporting your first issue and our team will be working towards fixing the issue for you or reach out for more background information. We recommend joining our [Slack Community](https://localstack.cloud/contact/) for real-time help and drop a message to LocalStack Pro Support if you are a Pro user! If you are willing to contribute towards fixing this issue, please have a look at our [contributing guidelines](https://github.com/localstack/.github/blob/main/CONTRIBUTING.md) and our [contributing guide](https://docs.localstack.cloud/contributing/).
Thanks for reporting @christopher-nash, that's an interesting development and something we can look into

Resolving patch:
diff --git a/localstack/services/sqs/models.py b/localstack/services/sqs/models.py
--- a/localstack/services/sqs/models.py
+++ b/localstack/services/sqs/models.py
@@ -6,6 +6,7 @@
 import re
 import threading
 import time
+from datetime import datetime
 from queue import Empty, PriorityQueue, Queue
 from typing import Dict, Optional, Set
 
@@ -27,11 +28,13 @@
 )
 from localstack.services.sqs.utils import (
     decode_receipt_handle,
+    encode_move_task_handle,
     encode_receipt_handle,
     global_message_sequence,
     is_message_deduplication_id_required,
 )
 from localstack.services.stores import AccountRegionBundle, BaseStore, LocalAttribute
+from localstack.utils.strings import long_uid
 from localstack.utils.time import now
 from localstack.utils.urls import localstack_host
 
@@ -185,6 +188,54 @@ def __init__(self):
         self.dead_letter_messages = []
 
 
+class MessageMoveTaskStatus(str):
+    CREATED = "CREATED"  # not validated, for internal use
+    RUNNING = "RUNNING"
+    COMPLETED = "COMPLETED"
+    CANCELLING = "CANCELLING"
+    CANCELLED = "CANCELLED"
+    FAILED = "FAILED"
+
+
+class MessageMoveTask:
+    """
+    A task created by the ``StartMessageMoveTask`` operation.
+    """
+
+    # configurable fields
+    source_arn: str
+    destination_arn: str
+    max_number_of_messages_per_second: int | None = None
+
+    # dynamic fields
+    task_id: str
+    status: str = MessageMoveTaskStatus.CREATED
+    started_timestamp: datetime | None = None
+    approximate_number_of_messages_moved: int | None = None
+    approximate_number_of_messages_to_move: int | None = None
+    failure_reason: str | None = None
+
+    cancel_event: threading.Event
+
+    def __init__(
+        self, source_arn: str, destination_arn: str, max_number_of_messages_per_second: int = None
+    ):
+        self.task_id = long_uid()
+        self.source_arn = source_arn
+        self.destination_arn = destination_arn
+        self.max_number_of_messages_per_second = max_number_of_messages_per_second
+        self.cancel_event = threading.Event()
+
+    def mark_started(self):
+        self.started_timestamp = datetime.utcnow()
+        self.status = MessageMoveTaskStatus.RUNNING
+        self.cancel_event.clear()
+
+    @property
+    def task_handle(self) -> str:
+        return encode_move_task_handle(self.task_id, self.source_arn)
+
+
 class SqsQueue:
     name: str
     region: str
@@ -640,12 +691,12 @@ def put(
         if message_deduplication_id:
             raise InvalidParameterValueException(
                 f"Value {message_deduplication_id} for parameter MessageDeduplicationId is invalid. Reason: The "
-                f"request includes a parameter that is not valid for this queue type. "
+                f"request includes a parameter that is not valid for this queue type."
             )
         if message_group_id:
             raise InvalidParameterValueException(
                 f"Value {message_group_id} for parameter MessageGroupId is invalid. Reason: The request includes a "
-                f"parameter that is not valid for this queue type. "
+                f"parameter that is not valid for this queue type."
             )
 
         standard_message = SqsMessage(time.time(), message)
@@ -1137,6 +1188,9 @@ class SqsStore(BaseStore):
 
     deleted: Dict[str, float] = LocalAttribute(default=dict)
 
+    move_tasks: Dict[str, MessageMoveTask] = LocalAttribute(default=dict)
+    """Maps task IDs to their ``MoveMessageTask`` object. Task IDs can be found by decoding a task handle."""
+
     def expire_deleted(self):
         for k in list(self.deleted.keys()):
             if self.deleted[k] <= (time.time() - sqs_constants.RECENTLY_DELETED_TIMEOUT):
diff --git a/localstack/services/sqs/provider.py b/localstack/services/sqs/provider.py
--- a/localstack/services/sqs/provider.py
+++ b/localstack/services/sqs/provider.py
@@ -9,6 +9,7 @@
 from itertools import islice
 from typing import Dict, List, Optional, Tuple
 
+from botocore.utils import InvalidArnException
 from moto.sqs.models import BINARY_TYPE_FIELD_INDEX, STRING_TYPE_FIELD_INDEX
 from moto.sqs.models import Message as MotoMessage
 
@@ -22,6 +23,7 @@
     BatchRequestTooLong,
     BatchResultErrorEntry,
     BoxedInteger,
+    CancelMessageMoveTaskResult,
     ChangeMessageVisibilityBatchRequestEntryList,
     ChangeMessageVisibilityBatchResult,
     CreateQueueResult,
@@ -35,6 +37,8 @@
     InvalidBatchEntryId,
     InvalidMessageContents,
     ListDeadLetterSourceQueuesResult,
+    ListMessageMoveTasksResult,
+    ListMessageMoveTasksResultEntry,
     ListQueuesResult,
     ListQueueTagsResult,
     Message,
@@ -50,11 +54,13 @@
     QueueDoesNotExist,
     QueueNameExists,
     ReceiveMessageResult,
+    ResourceNotFoundException,
     SendMessageBatchRequestEntryList,
     SendMessageBatchResult,
     SendMessageBatchResultEntry,
     SendMessageResult,
     SqsApi,
+    StartMessageMoveTaskResult,
     String,
     TagKeyList,
     TagMap,
@@ -71,6 +77,8 @@
 from localstack.services.sqs.exceptions import InvalidParameterValueException
 from localstack.services.sqs.models import (
     FifoQueue,
+    MessageMoveTask,
+    MessageMoveTaskStatus,
     SqsMessage,
     SqsQueue,
     SqsStore,
@@ -78,6 +86,7 @@
     sqs_stores,
 )
 from localstack.services.sqs.utils import (
+    decode_move_task_handle,
     generate_message_id,
     is_fifo_queue,
     is_message_deduplication_id_required,
@@ -404,6 +413,116 @@ def stop(self):
             self.scheduler = None
 
 
+class MessageMoveTaskManager:
+    """
+    Manages and runs MessageMoveTasks.
+
+    TODO: we should check how AWS really moves messages internally: do they use the API?
+     it's hard to know how AWS really does moving of messages. there are a number of things we could do
+     to understand it better, including creating a DLQ chain and letting move tasks fail to see whether
+     move tasks cause message consuming and create receipt handles. for now, we're doing a middle-layer
+     transactional move, foregoing the API layer but using receipt handles and transactions.
+
+    TODO: restoring move tasks from persistence doesn't work, may be a fringe case though
+    """
+
+    def __init__(self) -> None:
+        self.mutex = threading.RLock()
+        self.move_tasks: dict[str, MessageMoveTask] = dict()
+        self.executor = ThreadPoolExecutor(max_workers=100, thread_name_prefix="sqs-move-message")
+
+    def submit(self, move_task: MessageMoveTask):
+        with self.mutex:
+            try:
+                source_queue = self._get_queue_by_arn(move_task.source_arn)
+                move_task.approximate_number_of_messages_to_move = (
+                    source_queue.approx_number_of_messages
+                )
+                move_task.approximate_number_of_messages_moved = 0
+                move_task.mark_started()
+                self.move_tasks[move_task.task_id] = move_task
+                self.executor.submit(self._run, move_task)
+            except Exception as e:
+                self._fail_task(move_task, e)
+                raise
+
+    def cancel(self, move_task: MessageMoveTask):
+        with self.mutex:
+            move_task.status = MessageMoveTaskStatus.CANCELLING
+            move_task.cancel_event.set()
+
+    def close(self):
+        with self.mutex:
+            for move_task in self.move_tasks.values():
+                move_task.cancel_event.set()
+
+            self.executor.shutdown(wait=False)
+
+    def _run(self, move_task: MessageMoveTask):
+        try:
+            LOG.info(
+                "Move task started %s (%s -> %s)",
+                move_task.task_id,
+                move_task.source_arn,
+                move_task.destination_arn,
+            )
+            while not move_task.cancel_event.is_set():
+                # look up queues every time in case they are removed
+                source_queue = self._get_queue_by_arn(move_task.source_arn)
+                destination_queue = self._get_queue_by_arn(move_task.destination_arn)
+
+                receive_result = source_queue.receive(num_messages=1, visibility_timeout=1)
+
+                if receive_result.dead_letter_messages:
+                    raise NotImplementedError("Cannot deal with DLQ chains in move tasks")
+
+                if not receive_result.successful:
+                    # queue empty, task done
+                    break
+
+                message = receive_result.successful[0]
+                receipt_handle = receive_result.receipt_handles[0]
+                destination_queue.put(message=message.message)
+                source_queue.remove(receipt_handle)
+                move_task.approximate_number_of_messages_moved += 1
+
+                if rate := move_task.max_number_of_messages_per_second:
+                    move_task.cancel_event.wait(timeout=(1 / rate))
+
+        except Exception as e:
+            self._fail_task(move_task, e)
+        else:
+            if move_task.cancel_event.is_set():
+                LOG.info("Move task cancelled %s", move_task.task_id)
+                move_task.status = MessageMoveTaskStatus.CANCELLED
+            else:
+                LOG.info("Move task completed successfully %s", move_task.task_id)
+                move_task.status = MessageMoveTaskStatus.COMPLETED
+
+    def _get_queue_by_arn(self, queue_arn: str) -> SqsQueue:
+        arn = parse_arn(queue_arn)
+        return SqsProvider._require_queue(arn["account"], arn["region"], arn["resource"])
+
+    def _fail_task(self, task: MessageMoveTask, reason: Exception):
+        """
+        Marks a given task as failed due to the given reason.
+
+        :param task: the task to mark as failed
+        :param reason: the failure reason
+        """
+        LOG.info(
+            "Exception occurred during move task %s: %s",
+            task.task_id,
+            reason,
+            exc_info=LOG.isEnabledFor(logging.DEBUG),
+        )
+        task.status = MessageMoveTaskStatus.FAILED
+        if isinstance(reason, ServiceException):
+            task.failure_reason = reason.code
+        else:
+            task.failure_reason = reason.__class__.__name__
+
+
 def check_attributes(message_attributes: MessageBodyAttributeMap):
     if not message_attributes:
         return
@@ -621,6 +740,7 @@ class SqsProvider(SqsApi, ServiceLifecycleHook):
     def __init__(self) -> None:
         super().__init__()
         self._queue_update_worker = QueueUpdateWorker()
+        self._message_move_task_manager = MessageMoveTaskManager()
         self._router_rules = []
         self._init_cloudwatch_metrics_reporting()
 
@@ -638,6 +758,7 @@ def on_before_stop(self):
             ROUTER.remove_rule(rule)
 
         self._queue_update_worker.stop()
+        self._message_move_task_manager.close()
         self._stop_cloudwatch_metrics_reporting()
 
     @staticmethod
@@ -1109,11 +1230,8 @@ def list_dead_letter_source_queues(
         store = self.get_store(context.account_id, context.region)
         dead_letter_queue = self._resolve_queue(context, queue_url=queue_url)
         for queue in store.queues.values():
-            policy = queue.attributes.get(QueueAttributeName.RedrivePolicy)
-            if policy:
-                policy = json.loads(policy)
-                dlq_arn = policy.get("deadLetterTargetArn")
-                if dlq_arn == dead_letter_queue.arn:
+            if policy := queue.redrive_policy:
+                if policy.get("deadLetterTargetArn") == dead_letter_queue.arn:
                     urls.append(queue.url(context))
         return ListDeadLetterSourceQueuesResult(queueUrls=urls)
 
@@ -1219,6 +1337,168 @@ def set_queue_attributes(
                     f"maxReceiveCount: {max_receive_count}, valid values are from 1 to 1000 both inclusive."
                 )
 
+    def list_message_move_tasks(
+        self, context: RequestContext, source_arn: String, max_results: NullableInteger = None
+    ) -> ListMessageMoveTasksResult:
+        try:
+            self._require_queue_by_arn(context, source_arn)
+        except InvalidArnException:
+            raise InvalidParameterValueException(
+                "You must use this format to specify the SourceArn: arn:<partition>:<service>:<region>:<account-id>:<resource-id>"
+            )
+        except QueueDoesNotExist:
+            raise ResourceNotFoundException(
+                "The resource that you specified for the SourceArn parameter doesn't exist."
+            )
+
+        # get move tasks for queue and sort them by most-recent
+        store = self.get_store(context.account_id, context.region)
+        tasks = [
+            move_task
+            for move_task in store.move_tasks.values()
+            if move_task.source_arn == source_arn
+            and move_task.status != MessageMoveTaskStatus.CREATED
+        ]
+        tasks.sort(key=lambda t: t.started_timestamp, reverse=True)
+
+        # convert to result list
+        n = max_results or 1
+        return ListMessageMoveTasksResult(
+            Results=[self._to_message_move_task_entry(task) for task in tasks[:n]]
+        )
+
+    def _to_message_move_task_entry(
+        self, entity: MessageMoveTask
+    ) -> ListMessageMoveTasksResultEntry:
+        """
+        Converts a ``MoveMessageTask`` entity into a ``ListMessageMoveTasksResultEntry`` API concept.
+
+        :param entity: the entity to convert
+        :return: the typed dict for use in the AWS API
+        """
+        entry = ListMessageMoveTasksResultEntry(
+            SourceArn=entity.source_arn,
+            DestinationArn=entity.destination_arn,
+            Status=entity.status,
+        )
+
+        if entity.status == "RUNNING":
+            entry["TaskHandle"] = entity.task_handle
+        if entity.started_timestamp is not None:
+            entry["StartedTimestamp"] = int(entity.started_timestamp.timestamp() * 1000)
+        if entity.max_number_of_messages_per_second is not None:
+            entry["MaxNumberOfMessagesPerSecond"] = entity.max_number_of_messages_per_second
+        if entity.approximate_number_of_messages_to_move is not None:
+            entry[
+                "ApproximateNumberOfMessagesToMove"
+            ] = entity.approximate_number_of_messages_to_move
+        if entity.approximate_number_of_messages_moved is not None:
+            entry["ApproximateNumberOfMessagesMoved"] = entity.approximate_number_of_messages_moved
+        if entity.failure_reason is not None:
+            entry["FailureReason"] = entity.failure_reason
+
+        return entry
+
+    def start_message_move_task(
+        self,
+        context: RequestContext,
+        source_arn: String,
+        destination_arn: String = None,
+        max_number_of_messages_per_second: NullableInteger = None,
+    ) -> StartMessageMoveTaskResult:
+        try:
+            self._require_queue_by_arn(context, source_arn)
+        except QueueDoesNotExist as e:
+            raise ResourceNotFoundException(
+                "The resource that you specified for the SourceArn parameter doesn't exist.",
+                status_code=404,
+            ) from e
+
+        # check that destination queue exists
+        try:
+            self._require_queue_by_arn(context, destination_arn)
+        except QueueDoesNotExist as e:
+            raise ResourceNotFoundException(
+                "The resource that you specified for the DestinationArn parameter doesn't exist.",
+                status_code=404,
+            ) from e
+
+        # check that the source queue is the dlq of some other queue
+        is_dlq = False
+        for _, _, store in sqs_stores.iter_stores():
+            for queue in store.queues.values():
+                if not queue.redrive_policy:
+                    continue
+                if queue.redrive_policy.get("deadLetterTargetArn") == source_arn:
+                    is_dlq = True
+                    break
+            if is_dlq:
+                break
+        if not is_dlq:
+            raise InvalidParameterValueException(
+                "Source queue must be configured as a Dead Letter Queue."
+            )
+
+        # check that only one active task exists
+        with self._message_move_task_manager.mutex:
+            store = self.get_store(context.account_id, context.region)
+            tasks = [
+                task
+                for task in store.move_tasks.values()
+                if task.source_arn == source_arn
+                and task.status
+                in [
+                    MessageMoveTaskStatus.CREATED,
+                    MessageMoveTaskStatus.RUNNING,
+                    MessageMoveTaskStatus.CANCELLING,
+                ]
+            ]
+            if len(tasks) > 0:
+                raise InvalidParameterValueException(
+                    "There is already a task running. Only one active task is allowed for a source queue arn at a given time."
+                )
+
+            task = MessageMoveTask(source_arn, destination_arn, max_number_of_messages_per_second)
+            store.move_tasks[task.task_id] = task
+
+        self._message_move_task_manager.submit(task)
+
+        return StartMessageMoveTaskResult(TaskHandle=task.task_handle)
+
+    def cancel_message_move_task(
+        self,
+        context: RequestContext,
+        task_handle: String,
+    ) -> CancelMessageMoveTaskResult:
+        try:
+            task_id, source_arn = decode_move_task_handle(task_handle)
+        except ValueError as e:
+            raise InvalidParameterValueException(
+                "Value for parameter TaskHandle is invalid."
+            ) from e
+
+        try:
+            self._require_queue_by_arn(context, source_arn)
+        except QueueDoesNotExist as e:
+            raise ResourceNotFoundException(
+                "The resource that you specified for the SourceArn parameter doesn't exist.",
+                status_code=404,
+            ) from e
+
+        store = self.get_store(context.account_id, context.region)
+        try:
+            move_task = store.move_tasks[task_id]
+        except KeyError:
+            raise ResourceNotFoundException("Task does not exist.", status_code=404)
+
+        # TODO: what happens if move tasks are already cancelled?
+
+        self._message_move_task_manager.cancel(move_task)
+
+        return CancelMessageMoveTaskResult(
+            ApproximateNumberOfMessagesMoved=move_task.approximate_number_of_messages_moved,
+        )
+
     def tag_queue(self, context: RequestContext, queue_url: String, tags: TagMap) -> None:
         queue = self._resolve_queue(context, queue_url=queue_url)
 
diff --git a/localstack/services/sqs/utils.py b/localstack/services/sqs/utils.py
--- a/localstack/services/sqs/utils.py
+++ b/localstack/services/sqs/utils.py
@@ -1,5 +1,6 @@
 import base64
 import itertools
+import json
 import re
 import time
 from typing import Optional, Tuple
@@ -14,7 +15,7 @@
 )
 from localstack.utils.aws.arns import parse_arn
 from localstack.utils.objects import singleton_factory
-from localstack.utils.strings import long_uid
+from localstack.utils.strings import base64_decode, long_uid, to_bytes, to_str
 
 STANDARD_ENDPOINT = re.compile(STANDARD_STRATEGY_URL_REGEX)
 DOMAIN_ENDPOINT = re.compile(DOMAIN_STRATEGY_URL_REGEX)
@@ -105,6 +106,34 @@ def encode_receipt_handle(queue_arn, message) -> str:
     return encoded.decode("utf-8")
 
 
+def encode_move_task_handle(task_id: str, source_arn: str) -> str:
+    """
+    Move task handles are base64 encoded JSON dictionaries containing the task id and the source arn.
+
+    :param task_id: the move task id
+    :param source_arn: the source queue arn
+    :return: a string of a base64 encoded json doc
+    """
+    doc = f'{{"taskId":"{task_id}","sourceArn":"{source_arn}"}}'
+    return to_str(base64.b64encode(to_bytes(doc)))
+
+
+def decode_move_task_handle(handle: str | bytes) -> tuple[str, str]:
+    """
+    Inverse operation of ``encode_move_task_handle``.
+
+    :param handle: the base64 encoded task handle
+    :return: a tuple of task_id and source_arn
+    :raises ValueError: if the handle is not encoded correctly or does not contain the necessary fields
+    """
+    doc = json.loads(base64_decode(handle))
+    if "taskId" not in doc:
+        raise ValueError("taskId not found in handle")
+    if "sourceArn" not in doc:
+        raise ValueError("sourceArn not found in handle")
+    return doc["taskId"], doc["sourceArn"]
+
+
 @singleton_factory
 def global_message_sequence():
     # creates a 20-digit number used as the start for the global sequence


Test patch:
diff --git a/localstack/testing/snapshots/transformer_utility.py b/localstack/testing/snapshots/transformer_utility.py
--- a/localstack/testing/snapshots/transformer_utility.py
+++ b/localstack/testing/snapshots/transformer_utility.py
@@ -462,6 +462,7 @@ def sqs_api():
         """
         return [
             TransformerUtility.key_value("ReceiptHandle"),
+            TransformerUtility.key_value("TaskHandle"),
             TransformerUtility.key_value(
                 "SenderId"
             ),  # TODO: flaky against AWS (e.g. /Attributes/SenderId '<sender-id:1>' → '<sender-id:2>' ... (expected → actual))
diff --git a/tests/aws/services/sqs/test_sqs_move_task.py b/tests/aws/services/sqs/test_sqs_move_task.py
new file mode 100644
--- /dev/null
+++ b/tests/aws/services/sqs/test_sqs_move_task.py
@@ -0,0 +1,457 @@
+import json
+import time
+import uuid
+
+import pytest
+from botocore.exceptions import ClientError
+
+from localstack.constants import TEST_AWS_REGION_NAME
+from localstack.services.sqs.utils import decode_move_task_handle, encode_move_task_handle
+from localstack.testing.pytest import markers
+from localstack.utils.aws import arns
+from localstack.utils.sync import poll_condition, retry
+
+QueueUrl = str
+
+
+def sqs_collect_messages(
+    sqs_client, queue_url: str, expected: int, timeout: int, delete: bool = True
+):
+    collected = []
+
+    def _receive():
+        response = sqs_client.receive_message(
+            QueueUrl=queue_url,
+            # try not to wait too long, but also not poll too often
+            WaitTimeSeconds=min(max(1, timeout), 5),
+            MaxNumberOfMessages=1,
+        )
+
+        if messages := response.get("Messages"):
+            collected.extend(messages)
+
+            if delete:
+                for m in messages:
+                    sqs_client.delete_message(QueueUrl=queue_url, ReceiptHandle=m["ReceiptHandle"])
+
+        return len(collected) >= expected
+
+    if not poll_condition(_receive, timeout=timeout):
+        raise TimeoutError(
+            f"gave up waiting for messages (expected={expected}, actual={len(collected)}"
+        )
+
+    return collected
+
+
+def get_approx_number_of_messages(
+    sqs_client,
+    queue_url: str,
+) -> int:
+    response = sqs_client.get_queue_attributes(
+        QueueUrl=queue_url, AttributeNames=["ApproximateNumberOfMessages"]
+    )
+    return int(response["Attributes"]["ApproximateNumberOfMessages"])
+
+
+def sqs_wait_queue_size(
+    sqs_client,
+    queue_url: str,
+    expected_num_messages: int,
+    timeout: float = None,
+) -> int:
+    def _check_num_messages():
+        return get_approx_number_of_messages(sqs_client, queue_url) >= expected_num_messages
+
+    if not poll_condition(_check_num_messages, timeout=timeout):
+        raise TimeoutError(
+            f"gave up waiting for messages (expected={expected_num_messages}, "
+            f"actual={get_approx_number_of_messages(sqs_client, queue_url)})"
+        )
+
+    return get_approx_number_of_messages(sqs_client, queue_url)
+
+
+@pytest.fixture(autouse=True)
+def sqs_snapshot_transformer(snapshot):
+    snapshot.add_transformer(snapshot.transform.sqs_api())
+
+
+@pytest.fixture()
+def sqs_create_dlq_pipe(sqs_create_queue):
+    def _factory(max_receive_count: int = 1) -> tuple[QueueUrl, QueueUrl]:
+        dl_queue_url = sqs_create_queue()
+
+        # create redrive policy
+        url_parts = dl_queue_url.split("/")
+        dl_target_arn = arns.sqs_queue_arn(
+            url_parts[-1],
+            account_id=url_parts[len(url_parts) - 2],
+            region_name=TEST_AWS_REGION_NAME,
+        )
+        queue_url = sqs_create_queue(
+            Attributes={
+                "RedrivePolicy": json.dumps(
+                    {
+                        "deadLetterTargetArn": dl_target_arn,
+                        "maxReceiveCount": max_receive_count,
+                    }
+                )
+            },
+        )
+        return queue_url, dl_queue_url
+
+    return _factory
+
+
+@markers.aws.validated
+def test_cancel_with_invalid_task_handle(aws_client, snapshot):
+    with pytest.raises(ClientError) as e:
+        aws_client.sqs.cancel_message_move_task(TaskHandle="foobared")
+    snapshot.match("error", e.value.response)
+
+
+@markers.aws.validated
+def test_cancel_with_invalid_source_arn_in_task_handle(aws_client, snapshot):
+    source_arn = "arn:aws:sqs:us-east-1:878966065785:test-queue-doesnt-exist-123456"
+    task_handle = encode_move_task_handle("10f57157-fc38-4da9-a113-4de7e12d05dd", source_arn)
+
+    with pytest.raises(ClientError) as e:
+        aws_client.sqs.cancel_message_move_task(TaskHandle=task_handle)
+    snapshot.match("error", e.value.response)
+
+
+@markers.aws.validated
+def test_cancel_with_invalid_task_id_in_task_handle(
+    sqs_create_queue, sqs_get_queue_arn, aws_client, snapshot
+):
+    source_queue = sqs_create_queue()
+    source_arn = sqs_get_queue_arn(source_queue)
+
+    # this is just some non-existing task_id
+    task_handle = encode_move_task_handle("10f57157-fc38-4da9-a113-4de7e12d05aa", source_arn)
+    with pytest.raises(ClientError) as e:
+        aws_client.sqs.cancel_message_move_task(TaskHandle=task_handle)
+    snapshot.match("error", e.value.response)
+
+
+@markers.aws.validated
+def test_source_needs_redrive_policy(
+    sqs_create_queue,
+    sqs_get_queue_arn,
+    aws_client,
+    snapshot,
+):
+    sqs = aws_client.sqs
+
+    source_queue = sqs_create_queue()
+    source_arn = sqs_get_queue_arn(source_queue)
+
+    destination_queue = sqs_create_queue()
+    destination_arn = sqs_get_queue_arn(destination_queue)
+
+    with pytest.raises(ClientError) as e:
+        sqs.start_message_move_task(SourceArn=source_arn, DestinationArn=destination_arn)
+
+    snapshot.match("error", e.value.response)
+
+
+@markers.aws.validated
+def test_destination_needs_to_exist(
+    sqs_create_queue,
+    sqs_create_dlq_pipe,
+    sqs_get_queue_arn,
+    aws_client,
+    snapshot,
+):
+    sqs = aws_client.sqs
+    queue_url, dl_queue_url = sqs_create_dlq_pipe(max_receive_count=1)
+    source_arn = sqs_get_queue_arn(dl_queue_url)
+    destination_queue = sqs_create_queue()
+    destination_arn = sqs_get_queue_arn(destination_queue)
+    destination_arn += "doesntexist"
+
+    with pytest.raises(ClientError) as e:
+        sqs.start_message_move_task(SourceArn=source_arn, DestinationArn=destination_arn)
+
+    snapshot.match("error", e.value.response)
+
+
+@markers.aws.validated
+def test_basic_move_task_workflow(
+    sqs_create_queue,
+    sqs_create_dlq_pipe,
+    sqs_get_queue_arn,
+    aws_client,
+    snapshot,
+):
+    sqs = aws_client.sqs
+
+    # create dlq pipe: some-queue -> dlq (source) -> destination
+    queue_url, dl_queue_url = sqs_create_dlq_pipe(max_receive_count=1)
+    source_arn = sqs_get_queue_arn(dl_queue_url)
+    destination_queue = sqs_create_queue()
+    destination_arn = sqs_get_queue_arn(destination_queue)
+
+    # send two messages
+    sqs.send_message(QueueUrl=queue_url, MessageBody="message-1")
+    sqs.send_message(QueueUrl=queue_url, MessageBody="message-2")
+
+    # receive each message two times to move them into the dlq
+    sqs.receive_message(QueueUrl=queue_url, VisibilityTimeout=0)
+    sqs.receive_message(QueueUrl=queue_url, VisibilityTimeout=0)
+    sqs.receive_message(QueueUrl=queue_url, VisibilityTimeout=0)
+    sqs.receive_message(QueueUrl=queue_url, VisibilityTimeout=0)
+
+    # wait until the messages arrive in the DLQ
+    assert sqs_wait_queue_size(sqs, dl_queue_url, expected_num_messages=2, timeout=10) == 2
+
+    response = aws_client.sqs.start_message_move_task(
+        SourceArn=source_arn, DestinationArn=destination_arn
+    )
+    snapshot.match("start-message-move-task-response", response)
+
+    # check task handle format
+    task_handle = response["TaskHandle"]
+    decoded_task_id, decoded_source_arn = decode_move_task_handle(task_handle)
+    assert uuid.UUID(decoded_task_id)
+    assert decoded_source_arn == source_arn
+
+    # check that messages arrived in destination queue correctly
+    messages = sqs_collect_messages(sqs, destination_queue, expected=2, timeout=10)
+    assert {message["Body"] for message in messages} == {"message-1", "message-2"}
+
+    # check move task completion (in AWS, approximate number of messages may take a while to update)
+    def _wait_for_task_completion():
+        _response = aws_client.sqs.list_message_move_tasks(SourceArn=source_arn)
+        # this test also covers a check that `ApproximateNumberOfMessagesMoved` is set correctly at some point
+        assert int(_response["Results"][0]["ApproximateNumberOfMessagesMoved"]) == 2
+        return _response
+
+    response = retry(_wait_for_task_completion, retries=30, sleep=1)
+    snapshot.match("list-message-move-task-response", response)
+
+    # assert messages are no longer in DLQ
+    response = aws_client.sqs.receive_message(QueueUrl=dl_queue_url, WaitTimeSeconds=1)
+    assert not response.get("Messages")
+
+
+@markers.aws.validated
+def test_move_task_with_throughput_limit(
+    sqs_create_queue,
+    sqs_create_dlq_pipe,
+    sqs_get_queue_arn,
+    aws_client,
+    snapshot,
+):
+    sqs = aws_client.sqs
+
+    # create dlq pipe: some-queue -> dlq (source) -> destination
+    queue_url, dl_queue_url = sqs_create_dlq_pipe(max_receive_count=1)
+    source_arn = sqs_get_queue_arn(dl_queue_url)
+    destination_queue = sqs_create_queue()
+    destination_arn = sqs_get_queue_arn(destination_queue)
+
+    n = 4
+
+    # send n messages and move them into the DLQ
+    for i in range(n):
+        sqs.send_message(QueueUrl=queue_url, MessageBody=f"message-{i}")
+
+    assert sqs_wait_queue_size(sqs, queue_url, expected_num_messages=n, timeout=10) == n
+
+    # receive each message two times to move them into the dlq
+    for i in range(n * 2):
+        sqs.receive_message(QueueUrl=queue_url, VisibilityTimeout=0)
+
+    # wait until the messages arrive in the DLQ
+    assert sqs_wait_queue_size(sqs, dl_queue_url, expected_num_messages=n, timeout=10) == n
+
+    # start move task
+    response = aws_client.sqs.start_message_move_task(
+        SourceArn=source_arn, DestinationArn=destination_arn, MaxNumberOfMessagesPerSecond=1
+    )
+    snapshot.match("start-message-move-task-response", response)
+    started = time.time()
+    messages = sqs_collect_messages(sqs, destination_queue, n, 60)
+    assert {message["Body"] for message in messages} == {
+        "message-0",
+        "message-1",
+        "message-2",
+        "message-3",
+    }
+
+    # we set the MaxNumberOfMessagesPerSecond to 1, so moving 4 messages should take at least 3 seconds (assuming
+    # that the first one is moved immediately, and the task terminates immediately after the last message has been
+    # moved)
+    assert time.time() - started >= 3
+
+
+@markers.aws.validated
+@pytest.mark.skip_snapshot_verify(
+    paths=[
+        # this is non-deterministic because of concurrency in AWS vs LocalStack
+        "$..ApproximateNumberOfMessagesMoved",
+    ]
+)
+def test_move_task_cancel(
+    sqs_create_queue,
+    sqs_create_dlq_pipe,
+    sqs_get_queue_arn,
+    aws_client,
+    snapshot,
+):
+    sqs = aws_client.sqs
+
+    # create dlq pipe: some-queue -> dlq (source) -> destination
+    queue_url, dl_queue_url = sqs_create_dlq_pipe(max_receive_count=1)
+    source_arn = sqs_get_queue_arn(dl_queue_url)
+    destination_queue = sqs_create_queue()
+    destination_arn = sqs_get_queue_arn(destination_queue)
+
+    n = 10
+
+    # send n messages
+    for i in range(n):
+        sqs.send_message(QueueUrl=queue_url, MessageBody=f"message-{i}")
+
+    assert sqs_wait_queue_size(sqs, queue_url, expected_num_messages=n, timeout=10) == n
+
+    # receive each message two times to move them into the dlq
+    for i in range(n * 2):
+        sqs.receive_message(QueueUrl=queue_url, VisibilityTimeout=0)
+
+    # wait until the messages arrive in the DLQ
+    assert sqs_wait_queue_size(sqs, dl_queue_url, expected_num_messages=n, timeout=10) == n
+
+    # start move task
+    response = aws_client.sqs.start_message_move_task(
+        SourceArn=source_arn, DestinationArn=destination_arn, MaxNumberOfMessagesPerSecond=1
+    )
+    task_handle = response["TaskHandle"]
+
+    # wait for two messages to arrive, then cancel the task
+    messages = sqs_collect_messages(sqs, destination_queue, 2, 60)
+    assert len(messages) == 2
+
+    response = sqs.list_message_move_tasks(SourceArn=source_arn)
+    snapshot.match("list-while", response)
+
+    response = sqs.cancel_message_move_task(TaskHandle=task_handle)
+    snapshot.match("cancel", response)
+
+    # check move task completion (in AWS, approximate number of messages may take a while to update)
+    def _wait_for_task_cancellation():
+        _response = aws_client.sqs.list_message_move_tasks(SourceArn=source_arn)
+        assert _response["Results"][0]["Status"] == "CANCELLED"
+        return _response
+
+    response = retry(_wait_for_task_cancellation, retries=30, sleep=1)
+    snapshot.match("list-after", response)
+
+    # make sure that there are still messages left in the DLQ
+    assert aws_client.sqs.receive_message(QueueUrl=dl_queue_url)["Messages"]
+
+
+@markers.aws.validated
+@pytest.mark.skip_snapshot_verify(
+    paths=[
+        # this is non-deterministic because of concurrency in AWS vs LocalStack
+        "$..Results..ApproximateNumberOfMessagesMoved",
+        # error serialization is still an issue ('AWS.SimpleQueueService.NonExistentQueue' vs
+        # 'QueueDoesNotExist')
+        "$..Results..FailureReason",
+    ]
+)
+def test_move_task_delete_destination_queue_while_running(
+    sqs_create_queue,
+    sqs_create_dlq_pipe,
+    sqs_get_queue_arn,
+    aws_client,
+    snapshot,
+):
+    sqs = aws_client.sqs
+
+    # create dlq pipe: some-queue -> dlq (source) -> destination
+    queue_url, dl_queue_url = sqs_create_dlq_pipe(max_receive_count=1)
+    source_arn = sqs_get_queue_arn(dl_queue_url)
+    destination_queue = sqs_create_queue()
+    destination_arn = sqs_get_queue_arn(destination_queue)
+
+    n = 10
+
+    # send n messages
+    for i in range(n):
+        sqs.send_message(QueueUrl=queue_url, MessageBody=f"message-{i}")
+
+    assert sqs_wait_queue_size(sqs, queue_url, expected_num_messages=n, timeout=10) == n
+
+    # receive each message two times to move them into the dlq
+    for i in range(n * 2):
+        sqs.receive_message(QueueUrl=queue_url, VisibilityTimeout=0)
+
+    # wait until the messages arrive in the DLQ
+    assert sqs_wait_queue_size(sqs, dl_queue_url, expected_num_messages=n, timeout=10) == n
+
+    # start move task
+    aws_client.sqs.start_message_move_task(
+        SourceArn=source_arn, DestinationArn=destination_arn, MaxNumberOfMessagesPerSecond=1
+    )
+
+    sqs.delete_queue(QueueUrl=destination_queue)
+
+    # check move task completion (in AWS, approximate number of messages may take a while to update)
+    def _wait_for_task_cancellation():
+        _response = aws_client.sqs.list_message_move_tasks(SourceArn=source_arn)
+        assert _response["Results"][0]["Status"] != "RUNNING"
+        return _response
+
+    # should fail
+    response = retry(_wait_for_task_cancellation, retries=30, sleep=1)
+    print(response)
+    snapshot.match("list", response)
+
+    # make sure that there are still messages left in the DLQ
+    assert aws_client.sqs.receive_message(QueueUrl=dl_queue_url)["Messages"]
+
+
+@markers.aws.validated
+def test_start_multiple_move_tasks(
+    sqs_create_queue,
+    sqs_create_dlq_pipe,
+    sqs_get_queue_arn,
+    aws_client,
+    snapshot,
+):
+    sqs = aws_client.sqs
+
+    # create dlq pipe: some-queue -> dlq (source) -> destination
+    queue_url, dl_queue_url = sqs_create_dlq_pipe(max_receive_count=1)
+    source_arn = sqs_get_queue_arn(dl_queue_url)
+    destination_queue = sqs_create_queue()
+    destination_arn = sqs_get_queue_arn(destination_queue)
+
+    n = 10
+
+    # send n messages
+    for i in range(n):
+        sqs.send_message(QueueUrl=queue_url, MessageBody=f"message-{i}")
+
+    assert sqs_wait_queue_size(sqs, queue_url, expected_num_messages=n, timeout=10) == n
+
+    # receive each message two times to move them into the dlq
+    for i in range(n * 2):
+        sqs.receive_message(QueueUrl=queue_url, VisibilityTimeout=0)
+
+    # wait until the messages arrive in the DLQ
+    assert sqs_wait_queue_size(sqs, dl_queue_url, expected_num_messages=n, timeout=10) == n
+
+    # start move task
+    aws_client.sqs.start_message_move_task(
+        SourceArn=source_arn, DestinationArn=destination_arn, MaxNumberOfMessagesPerSecond=1
+    )
+    with pytest.raises(ClientError) as e:
+        aws_client.sqs.start_message_move_task(
+            SourceArn=source_arn, DestinationArn=destination_arn, MaxNumberOfMessagesPerSecond=1
+        )
+    snapshot.match("error", e.value.response)
diff --git a/tests/aws/services/sqs/test_sqs_move_task.snapshot.json b/tests/aws/services/sqs/test_sqs_move_task.snapshot.json
new file mode 100644
--- /dev/null
+++ b/tests/aws/services/sqs/test_sqs_move_task.snapshot.json
@@ -0,0 +1,217 @@
+{
+  "tests/aws/services/sqs/test_sqs_move_task.py::test_basic_move_task_workflow": {
+    "recorded-date": "03-01-2024, 20:25:54",
+    "recorded-content": {
+      "start-message-move-task-response": {
+        "TaskHandle": "<task-handle:1>",
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      },
+      "list-message-move-task-response": {
+        "Results": [
+          {
+            "ApproximateNumberOfMessagesMoved": 2,
+            "ApproximateNumberOfMessagesToMove": 2,
+            "DestinationArn": "arn:aws:sqs:<region>:111111111111:<resource:1>",
+            "SourceArn": "arn:aws:sqs:<region>:111111111111:<resource:2>",
+            "StartedTimestamp": "timestamp",
+            "Status": "COMPLETED"
+          }
+        ],
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      }
+    }
+  },
+  "tests/aws/services/sqs/test_sqs_move_task.py::test_source_needs_redrive_policy": {
+    "recorded-date": "03-01-2024, 19:49:15",
+    "recorded-content": {
+      "error": {
+        "Error": {
+          "Code": "InvalidParameterValue",
+          "Message": "Source queue must be configured as a Dead Letter Queue.",
+          "QueryErrorCode": "InvalidParameterValueException",
+          "Type": "Sender"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 400
+        }
+      }
+    }
+  },
+  "tests/aws/services/sqs/test_sqs_move_task.py::test_cancel_with_invalid_task_handle": {
+    "recorded-date": "03-01-2024, 19:50:39",
+    "recorded-content": {
+      "error": {
+        "Error": {
+          "Code": "InvalidParameterValue",
+          "Message": "Value for parameter TaskHandle is invalid.",
+          "QueryErrorCode": "InvalidParameterValueException",
+          "Type": "Sender"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 400
+        }
+      }
+    }
+  },
+  "tests/aws/services/sqs/test_sqs_move_task.py::test_cancel_with_invalid_source_arn_in_task_handle": {
+    "recorded-date": "03-01-2024, 19:56:40",
+    "recorded-content": {
+      "error": {
+        "Error": {
+          "Code": "ResourceNotFoundException",
+          "Message": "The resource that you specified for the SourceArn parameter doesn't exist.",
+          "QueryErrorCode": "ResourceNotFoundException",
+          "Type": "Sender"
+        },
+        "message": "The resource that you specified for the SourceArn parameter doesn't exist.",
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 404
+        }
+      }
+    }
+  },
+  "tests/aws/services/sqs/test_sqs_move_task.py::test_cancel_with_invalid_task_id_in_task_handle": {
+    "recorded-date": "03-01-2024, 19:57:32",
+    "recorded-content": {
+      "error": {
+        "Error": {
+          "Code": "ResourceNotFoundException",
+          "Message": "Task does not exist.",
+          "QueryErrorCode": "ResourceNotFoundException",
+          "Type": "Sender"
+        },
+        "message": "Task does not exist.",
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 404
+        }
+      }
+    }
+  },
+  "tests/aws/services/sqs/test_sqs_move_task.py::test_move_task_with_throughput_limit": {
+    "recorded-date": "03-01-2024, 20:25:11",
+    "recorded-content": {
+      "start-message-move-task-response": {
+        "TaskHandle": "<task-handle:1>",
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      }
+    }
+  },
+  "tests/aws/services/sqs/test_sqs_move_task.py::test_destination_needs_to_exist": {
+    "recorded-date": "03-01-2024, 20:12:56",
+    "recorded-content": {
+      "error": {
+        "Error": {
+          "Code": "ResourceNotFoundException",
+          "Message": "The resource that you specified for the DestinationArn parameter doesn't exist.",
+          "QueryErrorCode": "ResourceNotFoundException",
+          "Type": "Sender"
+        },
+        "message": "The resource that you specified for the DestinationArn parameter doesn't exist.",
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 404
+        }
+      }
+    }
+  },
+  "tests/aws/services/sqs/test_sqs_move_task.py::test_move_task_cancel": {
+    "recorded-date": "03-01-2024, 20:34:27",
+    "recorded-content": {
+      "list-while": {
+        "Results": [
+          {
+            "ApproximateNumberOfMessagesMoved": 0,
+            "ApproximateNumberOfMessagesToMove": 10,
+            "DestinationArn": "arn:aws:sqs:<region>:111111111111:<resource:1>",
+            "MaxNumberOfMessagesPerSecond": 1,
+            "SourceArn": "arn:aws:sqs:<region>:111111111111:<resource:2>",
+            "StartedTimestamp": "timestamp",
+            "Status": "RUNNING",
+            "TaskHandle": "<task-handle:1>"
+          }
+        ],
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      },
+      "cancel": {
+        "ApproximateNumberOfMessagesMoved": 0,
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      },
+      "list-after": {
+        "Results": [
+          {
+            "ApproximateNumberOfMessagesMoved": 2,
+            "ApproximateNumberOfMessagesToMove": 10,
+            "DestinationArn": "arn:aws:sqs:<region>:111111111111:<resource:1>",
+            "MaxNumberOfMessagesPerSecond": 1,
+            "SourceArn": "arn:aws:sqs:<region>:111111111111:<resource:2>",
+            "StartedTimestamp": "timestamp",
+            "Status": "CANCELLED"
+          }
+        ],
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      }
+    }
+  },
+  "tests/aws/services/sqs/test_sqs_move_task.py::test_move_task_delete_destination_queue_while_running": {
+    "recorded-date": "03-01-2024, 20:47:31",
+    "recorded-content": {
+      "list": {
+        "Results": [
+          {
+            "ApproximateNumberOfMessagesMoved": 3,
+            "ApproximateNumberOfMessagesToMove": 10,
+            "DestinationArn": "arn:aws:sqs:<region>:111111111111:<resource:1>",
+            "FailureReason": "AWS.SimpleQueueService.NonExistentQueue",
+            "MaxNumberOfMessagesPerSecond": 1,
+            "SourceArn": "arn:aws:sqs:<region>:111111111111:<resource:2>",
+            "StartedTimestamp": "timestamp",
+            "Status": "FAILED"
+          }
+        ],
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      }
+    }
+  },
+  "tests/aws/services/sqs/test_sqs_move_task.py::test_start_multiple_move_tasks": {
+    "recorded-date": "03-01-2024, 20:57:58",
+    "recorded-content": {
+      "error": {
+        "Error": {
+          "Code": "InvalidParameterValue",
+          "Message": "There is already a task running. Only one active task is allowed for a source queue arn at a given time.",
+          "QueryErrorCode": "InvalidParameterValueException",
+          "Type": "Sender"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 400
+        }
+      }
+    }
+  }
+}
diff --git a/tests/aws/services/sqs/test_sqs_move_task.validation.json b/tests/aws/services/sqs/test_sqs_move_task.validation.json
new file mode 100644
--- /dev/null
+++ b/tests/aws/services/sqs/test_sqs_move_task.validation.json
@@ -0,0 +1,32 @@
+{
+  "tests/aws/services/sqs/test_sqs_move_task.py::test_basic_move_task_workflow": {
+    "last_validated_date": "2024-01-03T20:25:53+00:00"
+  },
+  "tests/aws/services/sqs/test_sqs_move_task.py::test_cancel_with_invalid_source_arn_in_task_handle": {
+    "last_validated_date": "2024-01-03T19:56:40+00:00"
+  },
+  "tests/aws/services/sqs/test_sqs_move_task.py::test_cancel_with_invalid_task_handle": {
+    "last_validated_date": "2024-01-03T19:50:39+00:00"
+  },
+  "tests/aws/services/sqs/test_sqs_move_task.py::test_cancel_with_invalid_task_id_in_task_handle": {
+    "last_validated_date": "2024-01-03T19:57:31+00:00"
+  },
+  "tests/aws/services/sqs/test_sqs_move_task.py::test_destination_needs_to_exist": {
+    "last_validated_date": "2024-01-03T20:12:55+00:00"
+  },
+  "tests/aws/services/sqs/test_sqs_move_task.py::test_move_task_cancel": {
+    "last_validated_date": "2024-01-03T20:34:27+00:00"
+  },
+  "tests/aws/services/sqs/test_sqs_move_task.py::test_move_task_delete_destination_queue_while_running": {
+    "last_validated_date": "2024-01-03T20:47:31+00:00"
+  },
+  "tests/aws/services/sqs/test_sqs_move_task.py::test_move_task_with_throughput_limit": {
+    "last_validated_date": "2024-01-03T20:25:11+00:00"
+  },
+  "tests/aws/services/sqs/test_sqs_move_task.py::test_source_needs_redrive_policy": {
+    "last_validated_date": "2024-01-03T19:49:15+00:00"
+  },
+  "tests/aws/services/sqs/test_sqs_move_task.py::test_start_multiple_move_tasks": {
+    "last_validated_date": "2024-01-03T20:57:58+00:00"
+  }
+}