Repository: feast-dev/feast

Issue:
[DynamoDB] BatchWriteItem operation: Provided list of item keys contains duplicates
## Expected Behavior 

Duplication should be handled if a partition key already exists in the batch to be written to DynamoDB.

## Current Behavior

The following exception raises when running the local test `test_online_retrieval[LOCAL:File:dynamodb-True]`

```bash
botocore.exceptions.ClientError: An error occurred (ValidationException) when calling the BatchWriteItem operation: Provided list of item keys contains duplicates
```

## Steps to reproduce

This is the output from the pytest log

```bash
environment = Environment(name='integration_test_63b98a_1', test_repo_config=LOCAL:File:dynamodb, feature_store=<feast.feature_store...sal.data_sources.file.FileDataSourceCreator object at 0x7fb91d38f730>, python_feature_server=False, worker_id='master')
universal_data_sources = (UniversalEntities(customer_vals=[1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011, 1012, 1013, 1014, ...object at 0x7fb905f6b7c0>, field_mapping=<feast.infra.offline_stores.file_source.FileSource object at 0x7fb905f794f0>))
full_feature_names = True

    @pytest.mark.integration
    @pytest.mark.universal
    @pytest.mark.parametrize("full_feature_names", [True, False], ids=lambda v: str(v))
    def test_online_retrieval(environment, universal_data_sources, full_feature_names):
        fs = environment.feature_store
        entities, datasets, data_sources = universal_data_sources
        feature_views = construct_universal_feature_views(data_sources)
    
        feature_service = FeatureService(
            "convrate_plus100",
            features=[feature_views.driver[["conv_rate"]], feature_views.driver_odfv],
        )
        feature_service_entity_mapping = FeatureService(
            name="entity_mapping",
            features=[
                feature_views.location.with_name("origin").with_join_key_map(
                    {"location_id": "origin_id"}
                ),
                feature_views.location.with_name("destination").with_join_key_map(
                    {"location_id": "destination_id"}
                ),
            ],
        )
    
        feast_objects = []
        feast_objects.extend(feature_views.values())
        feast_objects.extend(
            [
                driver(),
                customer(),
                location(),
                feature_service,
                feature_service_entity_mapping,
            ]
        )
        fs.apply(feast_objects)
>       fs.materialize(
            environment.start_date - timedelta(days=1),
            environment.end_date + timedelta(days=1),
        )

sdk/python/tests/integration/online_store/test_universal_online.py:426: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
sdk/python/feast/feature_store.py:1165: in materialize
    provider.materialize_single_feature_view(
sdk/python/feast/infra/passthrough_provider.py:164: in materialize_single_feature_view
    self.online_write_batch(
sdk/python/feast/infra/passthrough_provider.py:86: in online_write_batch
    self.online_store.online_write_batch(config, table, data, progress)
sdk/python/feast/infra/online_stores/dynamodb.py:208: in online_write_batch
    progress(1)
../venv/lib/python3.9/site-packages/boto3/dynamodb/table.py:168: in __exit__
    self._flush()
../venv/lib/python3.9/site-packages/boto3/dynamodb/table.py:144: in _flush
    response = self._client.batch_write_item(
../venv/lib/python3.9/site-packages/botocore/client.py:395: in _api_call
    return self._make_api_call(operation_name, kwargs)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <botocore.client.DynamoDB object at 0x7fb9056a0eb0>, operation_name = 'BatchWriteItem'
api_params = {'RequestItems': {'integration_test_63b98a_1.global_stats': [{'PutRequest': {'Item': {'entity_id': '361ad244a817acdb9c...Item': {'entity_id': '361ad244a817acdb9cb041cf7ee8b4b0', 'event_ts': '2022-04-03 16:00:00+00:00', 'values': {...}}}}]}}

    def _make_api_call(self, operation_name, api_params):
        operation_model = self._service_model.operation_model(operation_name)
        service_name = self._service_model.service_name
        history_recorder.record('API_CALL', {
            'service': service_name,
            'operation': operation_name,
            'params': api_params,
        })
        if operation_model.deprecated:
            logger.debug('Warning: %s.%s() is deprecated',
                         service_name, operation_name)
        request_context = {
            'client_region': self.meta.region_name,
            'client_config': self.meta.config,
            'has_streaming_input': operation_model.has_streaming_input,
            'auth_type': operation_model.auth_type,
        }
        request_dict = self._convert_to_request_dict(
            api_params, operation_model, context=request_context)
        resolve_checksum_context(request_dict, operation_model, api_params)
    
        service_id = self._service_model.service_id.hyphenize()
        handler, event_response = self.meta.events.emit_until_response(
            'before-call.{service_id}.{operation_name}'.format(
                service_id=service_id,
                operation_name=operation_name),
            model=operation_model, params=request_dict,
            request_signer=self._request_signer, context=request_context)
    
        if event_response is not None:
            http, parsed_response = event_response
        else:
            apply_request_checksum(request_dict)
            http, parsed_response = self._make_request(
                operation_model, request_dict, request_context)
    
        self.meta.events.emit(
            'after-call.{service_id}.{operation_name}'.format(
                service_id=service_id,
                operation_name=operation_name),
            http_response=http, parsed=parsed_response,
            model=operation_model, context=request_context
        )
    
        if http.status_code >= 300:
            error_code = parsed_response.get("Error", {}).get("Code")
            error_class = self.exceptions.from_code(error_code)
>           raise error_class(parsed_response, operation_name)
E           botocore.exceptions.ClientError: An error occurred (ValidationException) when calling the BatchWriteItem operation: Provided list of item keys contains duplicates

```

### Specifications

- Version: `feast 0.18.1`
- Platform: `Windows`

## Possible Solution

Overwrite by partition keys in `DynamoDB.online_write_batch()` method

```python
with table_instance.batch_writer(overwrite_by_pkeys=["entity_id"]) as batch:
    for entity_key, features, timestamp, created_ts in data:
                entity_id = compute_entity_id(entity_key)
```

This solution comes from [StackOverflow](https://stackoverflow.com/questions/56632960/dynamodb-batchwriteitem-provided-list-of-item-keys-contains-duplicates)

## Other Comments

This error prompt while developing #2358 , I can provide a solution to both in the same PR if possible.


Discussion:

Resolving patch:
diff --git a/sdk/python/feast/infra/online_stores/dynamodb.py b/sdk/python/feast/infra/online_stores/dynamodb.py
--- a/sdk/python/feast/infra/online_stores/dynamodb.py
+++ b/sdk/python/feast/infra/online_stores/dynamodb.py
@@ -191,21 +191,7 @@ def online_write_batch(
         table_instance = dynamodb_resource.Table(
             _get_table_name(online_config, config, table)
         )
-        with table_instance.batch_writer() as batch:
-            for entity_key, features, timestamp, created_ts in data:
-                entity_id = compute_entity_id(entity_key)
-                batch.put_item(
-                    Item={
-                        "entity_id": entity_id,  # PartitionKey
-                        "event_ts": str(utils.make_tzaware(timestamp)),
-                        "values": {
-                            k: v.SerializeToString()
-                            for k, v in features.items()  # Serialized Features
-                        },
-                    }
-                )
-                if progress:
-                    progress(1)
+        self._write_batch_non_duplicates(table_instance, data, progress)
 
     @log_exceptions_and_usage(online_store="dynamodb")
     def online_read(
@@ -299,6 +285,32 @@ def _sort_dynamodb_response(self, responses: list, order: list):
         _, table_responses_ordered = zip(*table_responses_ordered)
         return table_responses_ordered
 
+    @log_exceptions_and_usage(online_store="dynamodb")
+    def _write_batch_non_duplicates(
+        self,
+        table_instance,
+        data: List[
+            Tuple[EntityKeyProto, Dict[str, ValueProto], datetime, Optional[datetime]]
+        ],
+        progress: Optional[Callable[[int], Any]],
+    ):
+        """Deduplicate write batch request items on ``entity_id`` primary key."""
+        with table_instance.batch_writer(overwrite_by_pkeys=["entity_id"]) as batch:
+            for entity_key, features, timestamp, created_ts in data:
+                entity_id = compute_entity_id(entity_key)
+                batch.put_item(
+                    Item={
+                        "entity_id": entity_id,  # PartitionKey
+                        "event_ts": str(utils.make_tzaware(timestamp)),
+                        "values": {
+                            k: v.SerializeToString()
+                            for k, v in features.items()  # Serialized Features
+                        },
+                    }
+                )
+                if progress:
+                    progress(1)
+
 
 def _initialize_dynamodb_client(region: str, endpoint_url: Optional[str] = None):
     return boto3.client("dynamodb", region_name=region, endpoint_url=endpoint_url)


Test patch:
diff --git a/sdk/python/tests/unit/infra/online_store/test_dynamodb_online_store.py b/sdk/python/tests/unit/infra/online_store/test_dynamodb_online_store.py
--- a/sdk/python/tests/unit/infra/online_store/test_dynamodb_online_store.py
+++ b/sdk/python/tests/unit/infra/online_store/test_dynamodb_online_store.py
@@ -1,5 +1,7 @@
+from copy import deepcopy
 from dataclasses import dataclass
 
+import boto3
 import pytest
 from moto import mock_dynamodb2
 
@@ -162,7 +164,7 @@ def test_online_read(repo_config, n_samples):
     data = _create_n_customer_test_samples(n=n_samples)
     _insert_data_test_table(data, PROJECT, f"{TABLE_NAME}_{n_samples}", REGION)
 
-    entity_keys, features = zip(*data)
+    entity_keys, features, *rest = zip(*data)
     dynamodb_store = DynamoDBOnlineStore()
     returned_items = dynamodb_store.online_read(
         config=repo_config,
@@ -171,3 +173,24 @@ def test_online_read(repo_config, n_samples):
     )
     assert len(returned_items) == len(data)
     assert [item[1] for item in returned_items] == list(features)
+
+
+@mock_dynamodb2
+def test_write_batch_non_duplicates(repo_config):
+    """Test DynamoDBOnline Store deduplicate write batch request items."""
+    dynamodb_tbl = f"{TABLE_NAME}_batch_non_duplicates"
+    _create_test_table(PROJECT, dynamodb_tbl, REGION)
+    data = _create_n_customer_test_samples()
+    data_duplicate = deepcopy(data)
+    dynamodb_resource = boto3.resource("dynamodb", region_name=REGION)
+    table_instance = dynamodb_resource.Table(f"{PROJECT}.{dynamodb_tbl}")
+    dynamodb_store = DynamoDBOnlineStore()
+    # Insert duplicate data
+    dynamodb_store._write_batch_non_duplicates(
+        table_instance, data + data_duplicate, progress=None
+    )
+    # Request more items than inserted
+    response = table_instance.scan(Limit=20)
+    returned_items = response.get("Items", None)
+    assert returned_items is not None
+    assert len(returned_items) == len(data)
diff --git a/sdk/python/tests/utils/online_store_utils.py b/sdk/python/tests/utils/online_store_utils.py
--- a/sdk/python/tests/utils/online_store_utils.py
+++ b/sdk/python/tests/utils/online_store_utils.py
@@ -19,6 +19,8 @@ def _create_n_customer_test_samples(n=10):
                 "name": ValueProto(string_val="John"),
                 "age": ValueProto(int64_val=3),
             },
+            datetime.utcnow(),
+            None,
         )
         for i in range(n)
     ]
@@ -42,13 +44,13 @@ def _delete_test_table(project, tbl_name, region):
 def _insert_data_test_table(data, project, tbl_name, region):
     dynamodb_resource = boto3.resource("dynamodb", region_name=region)
     table_instance = dynamodb_resource.Table(f"{project}.{tbl_name}")
-    for entity_key, features in data:
+    for entity_key, features, timestamp, created_ts in data:
         entity_id = compute_entity_id(entity_key)
         with table_instance.batch_writer() as batch:
             batch.put_item(
                 Item={
                     "entity_id": entity_id,
-                    "event_ts": str(utils.make_tzaware(datetime.utcnow())),
+                    "event_ts": str(utils.make_tzaware(timestamp)),
                     "values": {k: v.SerializeToString() for k, v in features.items()},
                 }
             )