Repository: localstack/localstack

Issue:
bug: SQS: change_message_visibility doesn't seem to work
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

Message visibility cannot be extended.

```
upping vis 0
upping vis 1
upping vis 2
upping vis 3
upping vis 4
---------------------------------------------------------------------------
MessageNotInflight                        Traceback (most recent call last)
<ipython-input-7-32ca194989e3> in <cell line: 25>()
     26     print('upping vis', i)
     27     # Should not fail.
---> 28     client.change_message_visibility(
     29         QueueUrl=queue_url,
     30         ReceiptHandle=receipt_handle,

/opt/venv/lib/python3.9/site-packages/botocore/client.py in _api_call(self, *args, **kwargs)
    384                     "%s() only accepts keyword arguments." % py_operation_name)
    385             # The "self" in this scope is referring to the BaseClient.
--> 386             return self._make_api_call(operation_name, kwargs)
    387 
    388         _api_call.__name__ = str(py_operation_name)

/opt/venv/lib/python3.9/site-packages/botocore/client.py in _make_api_call(self, operation_name, api_params)
    703             error_code = parsed_response.get("Error", {}).get("Code")
    704             error_class = self.exceptions.from_code(error_code)
--> 705             raise error_class(parsed_response, operation_name)
    706         else:
    707             return parsed_response

MessageNotInflight: An error occurred (AWS.SimpleQueueService.MessageNotInflight) when calling the ChangeMessageVisibility operation: Unknown
```

### Expected Behavior

It should be possible to keep extending message visibility like it is the case in Moto and in real AWS.

### How are you starting LocalStack?

With a docker-compose file

### Steps To Reproduce

#### How are you starting localstack (e.g., `bin/localstack` command, arguments, or `docker-compose.yml`)

docker-compose
```
version: "3.9"
services:
  localstack:
    image: localstack/localstack:1.0.0
    environment:
      - DEFAULT_REGION=eu-west-1
      - SERVICES=s3,sns,sqs
    ipc: host
```

#### Client commands 

```
import time
import boto3

client = boto3.client(
    'sqs',
    endpoint_url="http://localstack:4566/",
    region_name='eu-west-1',
    aws_secret_access_key="x",
    aws_access_key_id="x",
    use_ssl=False,
)

queue = client.create_queue(QueueName="some_queue")
queue_url = queue['QueueUrl']

send_response = client.send_message(
    QueueUrl=queue_url,
    MessageBody='abc',
)

message = client.receive_message(QueueUrl=queue_url)
receipt_handle = message['Messages'][0]['ReceiptHandle']

for i in range(100):
    print('upping vis', i)
    # Should not fail.
    client.change_message_visibility(
        QueueUrl=queue_url,
        ReceiptHandle=receipt_handle,
        VisibilityTimeout=3,
    )
    time.sleep(1)
```


### Environment

```markdown
- OS: linux, docker-compose
- LocalStack: 1.0.0 docker
```


### Anything else?

Docs:

> The new timeout period takes effect from the time you call the ChangeMessageVisibility action. In addition, the new timeout period applies only to the particular receipt of the message. ChangeMessageVisibility doesn't affect the timeout of later receipts of the message or later queues.

https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html

Undocumented AWS behaviour: https://github.com/spulec/moto/blob/1bbdbc2e0d6f729ebfcd5cf94cee80bc241bcd16/tests/test_sqs/test_sqs.py#L1622-L1625


Discussion:

Resolving patch:
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
@@ -169,6 +169,7 @@ class SqsMessage:
     receipt_handles: Set[str]
     last_received: Optional[float]
     first_received: Optional[float]
+    visibility_deadline: Optional[float]
     deleted: bool
     priority: float
     message_deduplication_id: str
@@ -209,11 +210,35 @@ def message_group_id(self) -> Optional[str]:
     def message_deduplication_id(self) -> Optional[str]:
         return self.message["Attributes"].get("MessageDeduplicationId")
 
+    def set_last_received(self, timestamp: float):
+        """
+        Sets the last received timestamp of the message to the given value, and updates the visibility deadline
+        accordingly.
+
+        :param timestamp: the last time the message was received
+        """
+        self.last_received = timestamp
+        self.visibility_deadline = timestamp + self.visibility_timeout
+
+    def update_visibility_timeout(self, timeout: int):
+        """
+        Sets the visibility timeout of the message to the given value, and updates the visibility deadline accordingly.
+
+        :param timeout: the timeout value in seconds
+        """
+        self.visibility_timeout = timeout
+        self.visibility_deadline = time.time() + timeout
+
     @property
-    def is_visible(self):
-        if self.last_received is None:
+    def is_visible(self) -> bool:
+        """
+        Returns false if the message has a visibility deadline that is in the future.
+
+        :return: whether the message is visibile or not.
+        """
+        if self.visibility_deadline is None:
             return True
-        if time.time() >= (self.last_received + self.visibility_timeout):
+        if time.time() >= self.visibility_deadline:
             return True
 
         return False
@@ -352,7 +377,7 @@ def update_visibility_timeout(self, receipt_handle: str, visibility_timeout: int
             if standard_message not in self.inflight:
                 raise MessageNotInflight()
 
-            standard_message.visibility_timeout = visibility_timeout
+            standard_message.update_visibility_timeout(visibility_timeout)
 
             if visibility_timeout == 0:
                 LOG.info(
@@ -430,7 +455,7 @@ def get(self, block=True, timeout=None, visibility_timeout: int = None) -> SqsMe
                     self.visibility_timeout if visibility_timeout is None else visibility_timeout
                 )
                 standard_message.receive_times += 1
-                standard_message.last_received = time.time()
+                standard_message.set_last_received(time.time())
                 if standard_message.first_received is None:
                     standard_message.first_received = standard_message.last_received
 


Test patch:
diff --git a/tests/integration/test_sqs.py b/tests/integration/test_sqs.py
--- a/tests/integration/test_sqs.py
+++ b/tests/integration/test_sqs.py
@@ -542,8 +542,66 @@ def test_receive_terminate_visibility_timeout(self, sqs_client, sqs_queue):
         result = sqs_client.receive_message(QueueUrl=queue_url)
         assert "Messages" not in result
 
-    def test_update_message_visibility_timeout(self):
-        pass
+    @pytest.mark.aws_validated
+    def test_extend_message_visibility_timeout_set_in_queue(self, sqs_client, sqs_create_queue):
+        queue_url = sqs_create_queue(Attributes={"VisibilityTimeout": "2"})
+
+        sqs_client.send_message(QueueUrl=queue_url, MessageBody="test")
+        response = sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=5)
+        receipt = response["Messages"][0]["ReceiptHandle"]
+
+        # update even if time expires
+        for _ in range(4):
+            time.sleep(1)
+            # we've waited a total of four seconds, although the visibility timeout is 2, so we are extending it
+            sqs_client.change_message_visibility(
+                QueueUrl=queue_url, ReceiptHandle=receipt, VisibilityTimeout=2
+            )
+            assert sqs_client.receive_message(QueueUrl=queue_url).get("Messages", []) == []
+
+        messages = sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=5)["Messages"]
+        assert messages[0]["Body"] == "test"
+        assert len(messages) == 1
+
+    @pytest.mark.aws_validated
+    def test_receive_message_with_visibility_timeout_updates_timeout(
+        self, sqs_client, sqs_create_queue
+    ):
+        queue_url = sqs_create_queue()
+
+        sqs_client.send_message(QueueUrl=queue_url, MessageBody="test")
+
+        response = sqs_client.receive_message(
+            QueueUrl=queue_url, WaitTimeSeconds=2, VisibilityTimeout=0
+        )
+        assert len(response["Messages"]) == 1
+
+        response = sqs_client.receive_message(QueueUrl=queue_url, VisibilityTimeout=3)
+        assert len(response["Messages"]) == 1
+
+        response = sqs_client.receive_message(QueueUrl=queue_url)
+        assert response.get("Messages", []) == []
+
+    @pytest.mark.aws_validated
+    def test_terminate_visibility_timeout_after_receive(self, sqs_client, sqs_create_queue):
+        queue_url = sqs_create_queue()
+
+        sqs_client.send_message(QueueUrl=queue_url, MessageBody="test")
+
+        response = sqs_client.receive_message(
+            QueueUrl=queue_url, WaitTimeSeconds=2, VisibilityTimeout=0
+        )
+        receipt_1 = response["Messages"][0]["ReceiptHandle"]
+        assert len(response["Messages"]) == 1
+
+        response = sqs_client.receive_message(QueueUrl=queue_url, VisibilityTimeout=3)
+        assert len(response["Messages"]) == 1
+
+        sqs_client.change_message_visibility(
+            QueueUrl=queue_url, ReceiptHandle=receipt_1, VisibilityTimeout=0
+        )
+        response = sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=1)
+        assert len(response["Messages"]) == 1
 
     def test_delete_message_batch_from_lambda(
         self, sqs_client, sqs_create_queue, lambda_client, create_lambda_function