Skip to content

Commit 4bcacc0

Browse files
feature: resolve conflicts according to strategies (#904)
The DataStore's sync engine will periodically try to publish local changes up to AppSync. If AppSync already has a copy of the model instance, a versioning conflict may occur. In this case, AppSync may return a `ConflictUnhandledError` to the client, in the GraphQL response error list. As a customer, you can supply a handler to define custom logic when such a conflict occurs. To do so, you provide a `DataStoreConflictHandler` to the `DataStoreConfiguration` when constructing the `AWSDataStorePlugin`: ```java DataStoreConfiguration config = DataStoreConfiguration.builder() .dataStoreConflictHandler(DataStoreConflictHandler.alwaysApplyRemote()) .build(); Amplify.addPlugin(new AWSDataStorePlugin(config)); ``` The ultimate goal of the user-provided handler will be to identify a `ConflictResolutionDecision`, to be used to address the conflict: 1. `ConflictResolutionDecision.applyRemote()`: Just accept the remote copy of the data, and overwrite whatever we have locally 2. `ConflictResolutionDecision.retryLocal()`: Try to publish the local mutation again. If it fails a second time, surface an error, and do not rety additional times. 3. `ConflictResolutionDecision.retry(T userProvidedModel)`: Try to publish a new version of the model up to AppSync. If using this decision, please take care to ensure the model ID matches the ID of the data in conflict. In the above configuration example, the `DataStoreConflictHandler.alwaysApplyRemote()` configures a handler that always chosen to accept the server's version of the data. A similar `DataStoreConfigurationHandler.alwaysRetryLocal()` is also provided for convenience. Fully-custom handlers may be provided as well, by implementing the `DataSToreConfigurationHandler` interface's single method, `onConflictDetected`. Once you have elected a strategy in the handler, the DataStore's sync engine will take over and fulfill the strategy you've requested. Prior to the current commit, the conflict handler didn't actually do anything on your behalf -- you were expected to code all resolution logic directly in your handler. This is no longer necessary. Resolves: #841
1 parent 77b5fbe commit 4bcacc0

22 files changed

+1173
-511
lines changed

aws-api-appsync/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dependencies {
2929
// https://github.com/robolectric/robolectric/issues/5245
3030
exclude group: 'com.google.auto.service', module: 'auto-service'
3131
}
32+
testImplementation dependency.jsonassert
3233
testImplementation project(path: ':testmodels')
3334
testImplementation project(path: ':testutils')
3435
}
Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,23 +23,29 @@
2323
import com.google.gson.JsonDeserializationContext;
2424
import com.google.gson.JsonDeserializer;
2525
import com.google.gson.JsonElement;
26+
import com.google.gson.JsonObject;
2627
import com.google.gson.JsonParseException;
28+
import com.google.gson.JsonSerializationContext;
29+
import com.google.gson.JsonSerializer;
2730

2831
import java.lang.reflect.ParameterizedType;
2932
import java.lang.reflect.Type;
33+
import java.util.Map;
3034
import java.util.Objects;
3135

3236
/**
3337
* Deserializes JSON into {@link ModelWithMetadata}.
3438
*/
35-
public final class ModelWithMetadataDeserializer implements JsonDeserializer<ModelWithMetadata<? extends Model>> {
39+
public final class ModelWithMetadataAdapter implements
40+
JsonDeserializer<ModelWithMetadata<? extends Model>>,
41+
JsonSerializer<ModelWithMetadata<? extends Model>> {
3642
/**
3743
* Register this deserializer into a {@link GsonBuilder}.
3844
* @param builder A {@link GsonBuilder}
3945
*/
4046
public static void register(@NonNull GsonBuilder builder) {
4147
Objects.requireNonNull(builder);
42-
builder.registerTypeAdapter(ModelWithMetadata.class, new ModelWithMetadataDeserializer());
48+
builder.registerTypeAdapter(ModelWithMetadata.class, new ModelWithMetadataAdapter());
4349
}
4450

4551
@Override
@@ -58,4 +64,24 @@ public ModelWithMetadata<? extends Model> deserialize(
5864
ModelMetadata metadata = context.deserialize(json, ModelMetadata.class);
5965
return new ModelWithMetadata<>(model, metadata);
6066
}
67+
68+
@Override
69+
public JsonElement serialize(
70+
ModelWithMetadata<? extends Model> src, Type typeOfSrc, JsonSerializationContext context) {
71+
JsonObject result = new JsonObject();
72+
73+
// Flatten out the fields of the model and its metadata into a flat key-value map.
74+
// To do this, serialize each individually, and then add the key/value pairs for each
75+
// object into a new container.
76+
JsonObject serializedMetadata = (JsonObject) context.serialize(src.getSyncMetadata());
77+
for (Map.Entry<java.lang.String, JsonElement> entry : serializedMetadata.entrySet()) {
78+
result.add(entry.getKey(), entry.getValue());
79+
}
80+
JsonObject serializedModel = (JsonObject) context.serialize(src.getModel());
81+
for (Map.Entry<java.lang.String, JsonElement> entry : serializedModel.entrySet()) {
82+
result.add(entry.getKey(), entry.getValue());
83+
}
84+
85+
return result;
86+
}
6187
}

aws-api-appsync/src/main/java/com/amplifyframework/util/GsonFactory.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import com.amplifyframework.core.model.query.predicate.GsonPredicateAdapters;
2020
import com.amplifyframework.core.model.temporal.GsonTemporalAdapters;
2121
import com.amplifyframework.core.model.types.GsonJavaTypeAdapters;
22-
import com.amplifyframework.datastore.appsync.ModelWithMetadataDeserializer;
22+
import com.amplifyframework.datastore.appsync.ModelWithMetadataAdapter;
2323

2424
import com.google.gson.Gson;
2525
import com.google.gson.GsonBuilder;
@@ -50,7 +50,7 @@ private static Gson create() {
5050
GsonJavaTypeAdapters.register(builder);
5151
GsonPredicateAdapters.register(builder);
5252
GsonResponseAdapters.register(builder);
53-
ModelWithMetadataDeserializer.register(builder);
53+
ModelWithMetadataAdapter.register(builder);
5454
return builder.create();
5555
}
5656
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package com.amplifyframework.datastore.appsync;
17+
18+
import com.amplifyframework.core.model.temporal.GsonTemporalAdapters;
19+
import com.amplifyframework.core.model.temporal.Temporal;
20+
import com.amplifyframework.testmodels.commentsblog.BlogOwner;
21+
import com.amplifyframework.testutils.Resources;
22+
import com.amplifyframework.util.TypeMaker;
23+
24+
import com.google.gson.Gson;
25+
import com.google.gson.GsonBuilder;
26+
import org.json.JSONException;
27+
import org.json.JSONObject;
28+
import org.junit.Assert;
29+
import org.junit.Before;
30+
import org.junit.Test;
31+
import org.skyscreamer.jsonassert.JSONAssert;
32+
33+
import java.lang.reflect.Type;
34+
import java.util.UUID;
35+
import java.util.concurrent.TimeUnit;
36+
37+
/**
38+
* Tests the {@link ModelWithMetadataAdapter}.
39+
*/
40+
public final class ModelWithMetadataAdapterTest {
41+
private Gson gson;
42+
43+
/**
44+
* We test the {@link ModelWithMetadataAdapter} through a vanilla Gson instance.
45+
*/
46+
@Before
47+
public void setup() {
48+
GsonBuilder builder = new GsonBuilder();
49+
ModelWithMetadataAdapter.register(builder);
50+
GsonTemporalAdapters.register(builder);
51+
this.gson = builder.create();
52+
}
53+
54+
/**
55+
* The Gson adapter can be used to serialize a ModelWithMetadata to JSON.
56+
* @throws JSONException From JSONAssert.assert(...) call, if invalid JSON
57+
* is provided in either position
58+
*/
59+
@Test
60+
public void adapterCanSerializeMwm() throws JSONException {
61+
Temporal.Timestamp lastChangedAt = Temporal.Timestamp.now();
62+
String modelId = UUID.randomUUID().toString();
63+
ModelMetadata metadata = new ModelMetadata(modelId, false, 4, lastChangedAt);
64+
BlogOwner model = BlogOwner.builder()
65+
.name("Blog Owner")
66+
.build();
67+
ModelWithMetadata<BlogOwner> mwm = new ModelWithMetadata<>(model, metadata);
68+
69+
String expected = new JSONObject()
70+
.put("id", model.getId())
71+
.put("name", model.getName())
72+
.put("_lastChangedAt", metadata.getLastChangedAt().getSecondsSinceEpoch())
73+
.put("_deleted", metadata.isDeleted())
74+
.put("_version", metadata.getVersion())
75+
.toString();
76+
String actual = gson.toJson(mwm);
77+
JSONAssert.assertEquals(expected, actual, true);
78+
}
79+
80+
/**
81+
* The Gson adapter can be used to deserialize JSON into a ModelWithMetadata object.
82+
*/
83+
@Test
84+
public void adapterCanDeserializeJsonIntoMwm() {
85+
// Arrange expected value
86+
BlogOwner model = BlogOwner.builder()
87+
.name("Tony Danielsen")
88+
.id("45a5f600-8aa8-41ac-a529-aed75036f5be")
89+
.build();
90+
Temporal.Timestamp lastChangedAt = new Temporal.Timestamp(1594858827, TimeUnit.SECONDS);
91+
ModelMetadata metadata = new ModelMetadata(model.getId(), false, 3, lastChangedAt);
92+
ModelWithMetadata<BlogOwner> expected = new ModelWithMetadata<>(model, metadata);
93+
94+
// Arrange some JSON, and then try to deserialize it
95+
String json = Resources.readAsString("blog-owner-with-metadata.json");
96+
Type type = TypeMaker.getParameterizedType(ModelWithMetadata.class, BlogOwner.class);
97+
ModelWithMetadata<BlogOwner> actual = gson.fromJson(json, type);
98+
99+
// Assert that the deserialized output matches out expected value
100+
Assert.assertEquals(expected, actual);
101+
}
102+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"id": "45a5f600-8aa8-41ac-a529-aed75036f5be",
3+
"name": "Tony Danielsen",
4+
"_lastChangedAt": 1594858827,
5+
"_version": 3,
6+
"_deleted": false
7+
}
8+

aws-datastore/src/main/java/com/amplifyframework/datastore/ApplyRemoteConflictHandler.java

Lines changed: 0 additions & 106 deletions
This file was deleted.

aws-datastore/src/main/java/com/amplifyframework/datastore/DataStoreConfiguration.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ public static DataStoreConfiguration defaults() throws DataStoreException {
108108
DataStoreErrorHandler errorHandler = DefaultDataStoreErrorHandler.instance();
109109
return builder()
110110
.errorHandler(errorHandler)
111-
.conflictHandler(ApplyRemoteConflictHandler.instance(errorHandler))
111+
.conflictHandler(DataStoreConflictHandler.alwaysApplyRemote())
112112
.syncInterval(DEFAULT_SYNC_INTERVAL_MINUTES, TimeUnit.MINUTES)
113113
.syncPageSize(DEFAULT_SYNC_PAGE_SIZE)
114114
.syncMaxRecords(DEFAULT_SYNC_MAX_RECORDS)
@@ -236,7 +236,7 @@ public static final class Builder {
236236

237237
private Builder() {
238238
this.errorHandler = DefaultDataStoreErrorHandler.instance();
239-
this.conflictHandler = ApplyRemoteConflictHandler.instance(errorHandler);
239+
this.conflictHandler = DataStoreConflictHandler.alwaysApplyRemote();
240240
this.ensureDefaults = false;
241241
}
242242

@@ -378,7 +378,7 @@ public DataStoreConfiguration build() throws DataStoreException {
378378
DefaultDataStoreErrorHandler.instance());
379379
conflictHandler = getValueOrDefault(
380380
conflictHandler,
381-
ApplyRemoteConflictHandler.instance(errorHandler));
381+
DataStoreConflictHandler.alwaysApplyRemote());
382382
syncIntervalInMinutes = getValueOrDefault(syncIntervalInMinutes, DEFAULT_SYNC_INTERVAL_MINUTES);
383383
syncMaxRecords = getValueOrDefault(syncMaxRecords, DEFAULT_SYNC_MAX_RECORDS);
384384
syncPageSize = getValueOrDefault(syncPageSize, DEFAULT_SYNC_PAGE_SIZE);

0 commit comments

Comments
 (0)