Resolving Conflicts in your Data Sync app
Introduction
Typically, mobile apps allow users to modify data while offline, which results in conflicts. A conflict occurs when two or more users try to modify the same data.
For example, a user modifies a record while offline, and another user deletes that record at the same time. The system needs to resolve the conflicting data.
Conflict resolution can be handled in two phases:
-
Conflict detection is the ability of an application to detect the possibility of incorrect data being stored.
-
Conflict resolution is the process of ensuring that the correct data is stored.
With AeroGear Data Sync:
-
You implement conflict detection exclusively in the code associated with mutations.
-
The Voyager Server module provides conflict detection on the server side.
-
The Voyager Client module provides conflict resolution on the client side.
Detecting conflicts on the server
A typical flow for detecting conflicts includes the following steps:
-
A Mutation Occurs - A client tries to modify or delete an object on the server using a GraphQL mutation
-
Read the Object - The server reads the current object that the client is trying to modify from the data source
-
Conflict Detection - The server compares the current object with the data sent by the client to see if there is a conflict. The developer chooses how the comparison is performed.
The aerogear/voyager-conflicts
module helps developers with the Conflict Detection steps regardless of the storage technology, while the fetching and storing of data is the responsibility of the developer.
This release supports the following implementations:
-
VersionedObjectState
- depends on the version field supplied in objects (the version field is used by default when importing conflictHandler). For details, please see: Implementing version based conflict detection -
HashObjectState
- depends on a hash calculated from the entire object. For details, please see: Implementing hash based conflict detection
These implementations are based on the ObjectState
interface and that interface can be extended to provide custom implementations for conflict detection.
-
GraphQL server with resolvers.
-
Database or any other form of data storage that can cause data conflicts. AeroGear recommends that you store data in a secure location. If you use a database, it is your responsibility to administer, maintain and backup that database. If you use any other form of data storage, you are responsible for backing up the data.
Implementing version based conflict detection
Version based conflict resolution is the recommended and simplest approach for conflict detection and resolution. The core idea is that every object has a version
property with an integer value. A conflict occurs when the version number sent by the client does not match the version stored in the server. This means a different client has already updated the object.
-
Import the @aerogear/voyager-conflicts package.
const { conflictHandler } = require('@aerogear/voyager-conflicts')
-
Add a version field to the GraphQL type that should support conflict resolution. The version should also be stored in the data storage.
type Task { title: String version: Int }
-
Add an example mutation.
type Mutation { updateTask(title: String!, version: Int!): Task }
-
Implement the resolver. Every conflict can be handled using a set of predefined steps, for example:
// 1. Read data from data source const serverData = db.find(clientData.id) // 2. Check for conflicts const conflict = conflictHandler.checkForConflicts(serverData, clientData) // 3. If there is a conflict, return the details to the client if(conflict) { throw conflict; } // 4. Save object to data source db.save(clientData.id, clientData)
In the example above, the throw
statement ensures that the client receives all necessary data to resolve the conflict client-side. For more information about this data, please see Structure of the Conflict Error.
Since the conflict will be resolved on the client, it is not required to persist the data. However, if there is no conflict, the data sent by the client should be persisted. For more information on resolving the conflict client-side, please see: Resolving Conflicts on the Client.
Implementing hash based conflict detection
Hash based conflict detection is a mechanism to detect conflicts based on the total object being updated by the client. It does this by hashing each object and comparing the hashes. This tells the server whether or not the objects are equivalent and can be considered conflict free.
-
Import the @aerogear/voyager-conflicts package.
const { HashObjectState } = require('@aerogear/voyager-conflicts')
-
When using the
HashObjectState
implementation, a hashing function must be provided. The function signature should be as follows:const hashFunction = (object) { // Using the Hash library of your choice const hashedObject = Hash(object) // return the hashedObject in string form return hashedObject; }
-
Provide this function when instantiating the
HashObjectState
:const conflictHandler = new HashObjectState(hashFunction)
-
Implement the resolver. Every conflict can be handled using a set of predefined steps, for example:
// 1. Read data from data source const serverData = db.find(clientData.id) // 2. Check for conflicts const conflict = conflictHandler.checkForConflicts(serverData, clientData) // 3. If there is a conflict, return the details to the client if(conflict) { throw conflict; } // 4. Save object to data source db.save(clientData.id, clientData)
In the example above, the throw
statement ensures the client receives all necessary data to resolve the conflict client-side. For more information about this data please see Structure of the Conflict Error.
Since the conflict will be resolved on the client, it is not required to persist the data. However, if there is no conflict, the data sent by the client should be persisted. For more information on resolving the conflict client-side, please see: Resolving Conflicts on the Client.
About the structure of the conflict error
The server needs to return a specific error when a conflict is detected containing both the server and client states. This allows the client to resolve the conflict.
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"exception": {
"conflictInfo": {
"serverState": {
//..
},
"clientState": {
//..
}
},
}
}
Resolving Conflicts on the client
A typical flow for resolving conflicts includes the following steps:
-
A Mutation Occurs - A client tries to modify or delete an object on the server using a GraphQL mutation.
-
Read the Object - The server reads the current object the client is trying to modify from the data source (usually a database).
-
Conflict Detection - The server compares the current object with the data sent by the client to see if there was a conflict. If there is a conflict, the server returns a response to the client containing information outlined in Structure of the Conflict Error
-
Conflict Resolution - The client attempts to resolve this conflict and makes a new request to the server in the hope that this data is no longer conflicted.
The conflict resolution implementation requires the following additions to your application:
-
A
returnType
added to the context of any mutation. see: Working With Conflict Resolution on the Client. -
Additional metadata inside types (for example version field) depending on the conflict implementation you chose. see: Version Based Conflict Detection.
-
Server-side resolvers to return conflicts back to clients first. For more information, see: Server Side Conflict Detection.
Developers can either use the default conflict resolution implementations, or implement their own conflict resolution implementations using the conflict resolution mechanism.
By default, when no changes are made on the same fields, the implementation attempts to resend the modified payload back to the server. When changes on the server and on the client affect the same fields, one of the specified conflict resolution strategies can be used. The default strategy applies client changes on top of the server side data. Developers can modify strategies to suit their needs.
Implementing conflict resolution on the client
To enable conflict resolution, the server side resolvers must be configured to perform conflict detection. Detection can rely on different implementations and return the conflict error back to the client. See Server Side Conflict Detection for more information.
Provide the mutation context with the returnType
parameter to resolve conflicts.
This parameter defines the Object type being operated on.
You can implement this in two ways:
-
If using Data Sync’s
offlineMutate
you can provide thereturnType
parameter directly as follows:client.offlineMutate({ ... returnType: 'Task' ... })
-
If using Apollo’s
mutate
function, provide thereturnType
parameter as follows:client.mutate({ ... context: { returnType: 'Task' } ... })
The client automatically resolves the conflicts based on the current strategy and notifies listeners as required.
Conflict resolution works with the recommended defaults and does not require any specific handling on the client.
For advanced use cases, the conflict implementation can be customised by supplying a custom conflictProvider in the application config. See Conflict Resolution Strategies below.
|
About the default conflict implementation
By default, conflict resolution is configured to rely on a version
field on each GraphQL type.
You must save a version field to the database in order to detect changes on the server.
For example:
type User {
id: ID!
version: String!
name: String!
}
The version field is controlled on the server and maps the last version that was sent from the server. All operations on the version field happen automatically. Make sure that the version field is always passed to the server for mutations that supports conflict resolution:
type Mutation {
updateUser(id: ID!, version: String!): User
}
Implementing conflict resolution strategies
Data Sync allows developers to define custom conflict resolution strategies. You can provide custom conflict resolution strategies to the client in the config by using the provided ConflictResolutionStrategies
type.
By default developers do not need to pass any strategy as UseClient
is the default.
Custom strategies can also be used to provide different resolution strategies for certain operations:
let customStrategy = {
resolve = (base, server, client, operationName) => {
let resolvedData;
switch (operationName) {
case "updateUser":
delete client.socialKey
resolvedData = Object.assign(base, server, client)
break
case "updateRole":
client.role = "none"
resolvedData = Object.assign(base, server, client)
break
default:
resolvedData = Object.assign(base, server, client)
}
return resolvedData
}
}
This custom strategy object provides two distinct strategies. The strategies are named to match the operation. You pass the name of the object as an argument to conflictStrategy in your config object:
let config = {
...
conflictStrategy: customStrategy
...
}
Listening to conflicts
Data Sync allows developers to receive information about the data conflict.
When a conflict occurs, Data Sync attempts to perform a field level resolution of data - it checks all fields of its type to see if both the client or server has changed the same field. The client can be notified in one of two scenarios.
-
If both client and server have changed any of the same fields, the
conflictOccurred
method of theConflictListener
is triggered. -
If the client and server have not changed any of the same fields, and the data can be easily merged, the
mergeOccurred
method of yourConflictListener
is triggered.
Developers can supply their own conflictListener
implementation, for example:
class ConflictLogger implements ConflictListener {
conflictOccurred(operationName, resolvedData, server, client) {
console.log("Conflict occurred with the following:")
console.log(`data: ${JSON.stringify(resolvedData)}, server: ${JSON.stringify(server)}, client: ${JSON.stringify(client)}, operation: ${JSON.stringify(operationName)}`);
}
mergeOccurred(operationName, resolvedData, server, client) {
console.log("Merge occurred with the following:")
console.log(`data: ${JSON.stringify(resolvedData)}, server: ${JSON.stringify(server)}, client: ${JSON.stringify(client)}, operation: ${JSON.stringify(operationName)}`);
}
}
let config = {
...
conflictListener: new ConflictLogger()
...
}
Handling pre-conflict errors
Data Sync provides a mechanism for developers to check for a 'pre-conflict' before a mutation occurs. It checks whether or not the data being sent conflicts locally. This happens when a mutation (or the act of creating a mutation) is initiated.
For example, consider a user performing the following actions:
-
opens a form
-
begins working on the pre-populated data on this form
-
the client receives new data from the server from subscriptions
-
the client is now conflicted but the user is unaware
-
when the user presses Submit Data Sync notices that their data is conflicted and provides the developer with the information to warn the user
To use this feature, and therefore potentially save unecessary round-trips to the server with data which is definitely conflicted, developers can make use of the error returned by Data Sync.
An example of how developers can use this error:
return client.offlineMutate({
...
}).then(result => {
// handle the result
}).catch(error => {
if (error.networkError && error.networkError.localConflict) {
// handle pre-conflict here by potentially
// providing an alert with a chance to update data before pressing send again
}
})