Repository: localstack/localstack

Issue:
bug: DynamoDB DescribeTable treats the queried table as a replica
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

The DynamoDB DescribeTable operation treats the current queried table as a replica.

```
$ awslocal dynamodb describe-table --table-name global01 --query 'Table.Replicas' --region us-west-1
[
    {
        "RegionName": "ap-south-1",
        "ReplicaStatus": "ACTIVE"
    },
    {
        "RegionName": "eu-central-1",
        "ReplicaStatus": "ACTIVE"
    },
    {
        "RegionName": "us-west-1",  # <<< THIS MUST NOT BE RETURNED
        "ReplicaStatus": "ACTIVE"   # <<<
    }
]
```

### Expected Behavior

The correct response should be like so:

```
$ awslocal dynamodb describe-table --table-name global01 --query 'Table.Replicas' --region us-west-1
[
    {
        "RegionName": "ap-south-1",
        "ReplicaStatus": "ACTIVE"
    },
    {
        "RegionName": "eu-central-1",
        "ReplicaStatus": "ACTIVE"
    }
]
```

### 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 run localstack/localstack

### Environment

```markdown
- OS: Ubuntu 22.04
- LocalStack: latest
```


### Anything else?

Localstack maintains a single copy of a global table rather than truly replicating it. Requests are forwarded to the region where the table exists. On the receiving region, it is not possible to know the originating region. This is a technical limitation which will be solvable once the new internal AWS client is stable, see https://github.com/localstack/localstack/pull/7240. Here it will be possible to use the DTO to determine the origin region.


Discussion:

Resolving patch:
diff --git a/localstack/services/dynamodb/models.py b/localstack/services/dynamodb/models.py
--- a/localstack/services/dynamodb/models.py
+++ b/localstack/services/dynamodb/models.py
@@ -1,5 +1,4 @@
-from typing import Dict, Set
-
+from localstack.aws.api.dynamodb import RegionName, ReplicaDescription, TableName
 from localstack.services.stores import (
     AccountRegionBundle,
     BaseStore,
@@ -7,32 +6,33 @@
     LocalAttribute,
 )
 
-TableName = str
-Region = str
-Replica = Dict[Region, Set[Region]]
-
 
 class DynamoDBStore(BaseStore):
-    # maps global table names to configurations
-    GLOBAL_TABLES: Dict[str, Dict] = CrossRegionAttribute(default=dict)
+    # maps global table names to configurations (for the legacy v.2017 tables)
+    GLOBAL_TABLES: dict[str, dict] = CrossRegionAttribute(default=dict)
+
+    # Maps table name to the region they exist in on DDBLocal (for v.2019 global tables)
+    TABLE_REGION: dict[TableName, RegionName] = CrossRegionAttribute(default=dict)
+
+    # Maps the table replicas (for v.2019 global tables)
+    REPLICAS: dict[TableName, dict[RegionName, ReplicaDescription]] = CrossRegionAttribute(
+        default=dict
+    )
 
     # cache table taggings - maps table ARN to tags dict
-    TABLE_TAGS: Dict[str, Dict] = CrossRegionAttribute(default=dict)
+    TABLE_TAGS: dict[str, dict] = CrossRegionAttribute(default=dict)
 
     # maps table names to cached table definitions
-    table_definitions: Dict[str, Dict] = LocalAttribute(default=dict)
+    table_definitions: dict[str, dict] = LocalAttribute(default=dict)
 
     # maps table names to additional table properties that are not stored upstream (e.g., ReplicaUpdates)
-    table_properties: Dict[str, Dict] = LocalAttribute(default=dict)
-
-    # maps the replicas for the v.2019 tables
-    REPLICA_UPDATES: Dict[TableName, Replica] = CrossRegionAttribute(default=dict)
+    table_properties: dict[str, dict] = LocalAttribute(default=dict)
 
     # maps table names to TTL specifications
-    ttl_specifications: Dict[str, Dict] = LocalAttribute(default=dict)
+    ttl_specifications: dict[str, dict] = LocalAttribute(default=dict)
 
     # maps backups
-    backups: Dict[str, Dict] = LocalAttribute(default=dict)
+    backups: dict[str, dict] = LocalAttribute(default=dict)
 
 
 dynamodb_stores = AccountRegionBundle("dynamodb", DynamoDBStore)
diff --git a/localstack/services/dynamodb/provider.py b/localstack/services/dynamodb/provider.py
--- a/localstack/services/dynamodb/provider.py
+++ b/localstack/services/dynamodb/provider.py
@@ -338,11 +338,14 @@ def modify_context_region(context: RequestContext, region: str):
         flags=re.IGNORECASE,
     )
 
-    yield context
-
-    # revert the original context
-    context.region = original_region
-    context.request.headers["Authorization"] = original_authorization
+    try:
+        yield context
+    except Exception:
+        raise
+    finally:
+        # revert the original context
+        context.region = original_region
+        context.request.headers["Authorization"] = original_authorization
 
 
 class DynamoDBProvider(DynamodbApi, ServiceLifecycleHook):
@@ -486,6 +489,7 @@ def create_table(
 
         backend = get_store(context.account_id, context.region)
         backend.table_definitions[table_name] = table_definitions = dict(create_table_input)
+        backend.TABLE_REGION[table_name] = context.region
 
         if "TableId" not in table_definitions:
             table_definitions["TableId"] = long_uid()
@@ -533,7 +537,10 @@ def delete_table(self, context: RequestContext, table_name: TableName) -> Delete
         table_arn = result.get("TableDescription", {}).get("TableArn")
         table_arn = self.fix_table_arn(table_arn)
         dynamodbstreams_api.delete_streams(table_arn)
-        get_store(context.account_id, context.region).TABLE_TAGS.pop(table_arn, None)
+
+        store = get_store(context.account_id, context.region)
+        store.TABLE_TAGS.pop(table_arn, None)
+        store.REPLICAS.pop(table_name, None)
 
         return result
 
@@ -552,21 +559,22 @@ def describe_table(self, context: RequestContext, table_name: TableName) -> Desc
         store = get_store(context.account_id, context.region)
 
         # Update replication details
-        replicas: dict[str, set[str]] = store.REPLICA_UPDATES.get(table_name, {})
+        replicas: Dict[RegionName, ReplicaDescription] = store.REPLICAS.get(table_name, {})
 
         replica_description_list = []
-        for source_region, replicated_regions in replicas.items():
-            # Contrary to AWS, we show all regions including the current context region where a replica exists
-            # This is due to the limitation of internal request forwarding mechanism for global tables
+
+        if global_table_region != context.region:
             replica_description_list.append(
-                ReplicaDescription(RegionName=source_region, ReplicaStatus=ReplicaStatus.ACTIVE)
-            )
-            for replicated_region in replicated_regions:
-                replica_description_list.append(
-                    ReplicaDescription(
-                        RegionName=replicated_region, ReplicaStatus=ReplicaStatus.ACTIVE
-                    )
+                ReplicaDescription(
+                    RegionName=global_table_region, ReplicaStatus=ReplicaStatus.ACTIVE
                 )
+            )
+
+        for replica_region, replica_description in replicas.items():
+            # The replica in the region being queried must not be returned
+            if replica_region != context.region:
+                replica_description_list.append(replica_description)
+
         table_description.update({"Replicas": replica_description_list})
 
         # update only TableId and SSEDescription if present
@@ -608,8 +616,7 @@ def update_table(
                 store = get_store(context.account_id, global_table_region)
 
                 # Dict with source region to set of replicated regions
-                replicas: dict[str, set(str)] = store.REPLICA_UPDATES.get(table_name, {})
-                replicas.setdefault(global_table_region, set())
+                replicas: Dict[RegionName, ReplicaDescription] = store.REPLICAS.get(table_name, {})
 
                 for replica_update in replica_updates:
                     for key, details in replica_update.items():
@@ -622,23 +629,28 @@ def update_table(
 
                         match key:
                             case "Create":
-                                if target_region in replicas[global_table_region]:
+                                if target_region in replicas.keys():
                                     raise ValidationException(
                                         f"Failed to create a the new replica of table with name: '{table_name}' because one or more replicas already existed as tables."
                                     )
-                                replicas[global_table_region].add(target_region)
+                                replicas[target_region] = ReplicaDescription(
+                                    RegionName=target_region,
+                                    KMSMasterKeyId=details.get("KMSMasterKeyId"),
+                                    ProvisionedThroughputOverride=details.get(
+                                        "ProvisionedThroughputOverride"
+                                    ),
+                                    GlobalSecondaryIndexes=details.get("GlobalSecondaryIndexes"),
+                                    ReplicaStatus=ReplicaStatus.ACTIVE,
+                                )
                             case "Delete":
                                 try:
-                                    replicas[global_table_region].remove(target_region)
-                                    if len(replicas[global_table_region]) == 0:
-                                        # Removing the set indicates that replication is disabled
-                                        replicas.pop(global_table_region)
+                                    replicas.pop(target_region)
                                 except KeyError:
                                     raise ValidationException(
                                         "Update global table operation failed because one or more replicas were not part of the global table."
                                     )
 
-                store.REPLICA_UPDATES[table_name] = replicas
+                store.REPLICAS[table_name] = replicas
 
             # update response content
             schema = SchemaExtractor.get_table_schema(
@@ -663,10 +675,10 @@ def list_tables(
         response = self.forward_request(context)
 
         # Add replicated tables
-        replicas = get_store(context.account_id, context.region).REPLICA_UPDATES
+        replicas = get_store(context.account_id, context.region).REPLICAS
         for replicated_table, replications in replicas.items():
-            for original_region, replicated_regions in replications.items():
-                if context.region in replicated_regions:
+            for replica_region, replica_description in replications.items():
+                if context.region == replica_region:
                     response["TableNames"].append(replicated_table)
 
         return response
@@ -1343,13 +1355,14 @@ def get_global_table_region(context: RequestContext, table_name: str) -> str:
         :param table_name: table name
         :return: region
         """
-        replicas = get_store(context.account_id, context.region).REPLICA_UPDATES.get(table_name)
-        if replicas:
-            global_table_region = list(replicas.keys())[0]
-            replicated_at = replicas[global_table_region]
-            # Ensure that a replica exists in the current context region, and that the table exists in DDB Local
-            if context.region == global_table_region or context.region in replicated_at:
-                return global_table_region
+        store = get_store(context.account_id, context.region)
+
+        table_region = store.TABLE_REGION.get(table_name)
+        replicated_at = store.REPLICAS.get(table_name, {}).keys()
+
+        if context.region == table_region or context.region in replicated_at:
+            return table_region
+
         return context.region
 
     @staticmethod


Test patch:
diff --git a/tests/integration/test_dynamodb.py b/tests/integration/test_dynamodb.py
--- a/tests/integration/test_dynamodb.py
+++ b/tests/integration/test_dynamodb.py
@@ -919,19 +919,23 @@ def test_global_tables_version_2019(
 
         # Replicate table in US and EU
         dynamodb_ap_south_1.update_table(
-            TableName=table_name, ReplicaUpdates=[{"Create": {"RegionName": "us-east-1"}}]
+            TableName=table_name,
+            ReplicaUpdates=[{"Create": {"RegionName": "us-east-1", "KMSMasterKeyId": "foo"}}],
         )
         dynamodb_ap_south_1.update_table(
-            TableName=table_name, ReplicaUpdates=[{"Create": {"RegionName": "eu-west-1"}}]
+            TableName=table_name,
+            ReplicaUpdates=[{"Create": {"RegionName": "eu-west-1", "KMSMasterKeyId": "bar"}}],
         )
 
         # Ensure all replicas can be described
         response = dynamodb_ap_south_1.describe_table(TableName=table_name)
-        assert len(response["Table"]["Replicas"]) == 3
+        assert len(response["Table"]["Replicas"]) == 2
         response = dynamodb_us_east_1.describe_table(TableName=table_name)
-        assert len(response["Table"]["Replicas"]) == 3
+        assert len(response["Table"]["Replicas"]) == 2
+        assert "bar" in [replica.get("KMSMasterKeyId") for replica in response["Table"]["Replicas"]]
         response = dynamodb_eu_west_1.describe_table(TableName=table_name)
-        assert len(response["Table"]["Replicas"]) == 3
+        assert len(response["Table"]["Replicas"]) == 2
+        assert "foo" in [replica.get("KMSMasterKeyId") for replica in response["Table"]["Replicas"]]
         with pytest.raises(Exception) as exc:
             dynamodb_sa_east_1.describe_table(TableName=table_name)
         exc.match("ResourceNotFoundException")
@@ -986,9 +990,9 @@ def test_global_tables_version_2019(
 
         # Ensure replica details are updated in other regions
         response = dynamodb_us_east_1.describe_table(TableName=table_name)
-        assert len(response["Table"]["Replicas"]) == 2
+        assert len(response["Table"]["Replicas"]) == 1
         response = dynamodb_ap_south_1.describe_table(TableName=table_name)
-        assert len(response["Table"]["Replicas"]) == 2
+        assert len(response["Table"]["Replicas"]) == 1
 
         # Ensure removing the last replica disables global table
         dynamodb_us_east_1.update_table(