Resolving Conflicts in your Data Sync app

Introduction

Mobile apps allow users to modify data while offline. This can result in conflicts.

A conflict occurs when two or more users try to modify the same data. 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 {org-name} Data Sync:

  • You implement conflict detection exclusively in the code associated with mutations.

  • The Data Sync 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:

  1. A Mutation Occurs - A client tries to modify or delete an object on the server using a GraphQL mutation

  2. Read the Object - The server reads the current object that the client is trying to modify from the data source

  3. 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:

These implementations are based on the ObjectState interface and that interface can be extended to provide custom implementations for conflict detection.

Prerequisites
  • GraphQL server with resolvers.

  • Database or any other form of data storage that can cause data conflicts. {org-name} 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.

Procedure
  1. Import the @aerogear/voyager-conflicts package.

    const { conflictHandler } = require('@aerogear/voyager-conflicts')
  2. 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
    }
  3. Add an example mutation.

    type Mutation {
      updateTask(title: String!, version: Int!): Task
    }
  4. 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.

Procedure
  1. Import the @aerogear/voyager-conflicts package.

    const { HashObjectState } = require('@aerogear/voyager-conflicts')
  2. 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;
    }
  3. Provide this function when instantiating the HashObjectState:

    const conflictHandler = new HashObjectState(hashFunction)
  4. 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:

  1. A Mutation Occurs - A client tries to modify or delete an object on the server using a GraphQL mutation.

  2. Read the Object - The server reads the current object the client is trying to modify from the data source (usually a database).

  3. 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

  4. 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:

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.

Procedure

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 the returnType parameter directly as follows:

    client.offlineMutate({
      ...
      returnType: 'Task'
      ...
    })
  • If using Apollo’s mutate function, provide the returnType 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 the ConflictListener 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 your ConflictListener 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:

  1. opens a form

  2. begins working on the pre-populated data on this form

  3. the client receives new data from the server from subscriptions

  4. the client is now conflicted but the user is unaware

  5. 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
  }
})