Control de concurrencia en Windows Azure Mobile Services

- 12 minutos de lectura

Desde hace unas semanas tenemos de serie en Windows Azure Mobile Services un mecanismo de detecci贸n de conflictos basado en un control optimista de concurrencia. Este mecanismo nos permite detectar conflictos cuando se realizan cambios sobre la misma entidad al mismo tiempo. Sin este control, el 煤ltimo cambio que se hace siempre sobrescribe cualquier cambio anterior.

En esta entrada vamos a ver c贸mo funciona el control de concurrencia y c贸mo podemos detectar los conflictos tanto en una aplicaci贸n cliente como en el servidor y qu茅 acciones podemos realizar en caso de conflicto. Para mostrar los distintos ejemplos har茅 uso de una aplicaci贸n para la Windows Store que permite realizar el mantenimiento b谩sico de una entidad.

Nuevas propiedades de sistema

El control de concurrencia optimista permite verificar, al realizar una transacci贸n, que ninguna otra transacci贸n haya modificado los datos. Si se han realizado modificaciones, la transacci贸n es rechazada. Mobile Services hace el seguimiento de cambios de cada fila utilizando la propiedad de sistema __version, que contiene un timestamp que se actualiza cada vez que se realiza un cambio en la fila. Este campo, junto con __createdAt y __updateAt, son los nuevos campos que se agregan cuando creamos una nueva tabla.

El funcionamiento es bien sencillo, cuando realizamos una actualizaci贸n y el valor del campo __version no coincide con el valor de servidor, Mobile Services lanza una excepci贸n del tipo MobileServicePreconditionFailedException que podemos capturar para poder decidir qu茅 acci贸n realizar.

Para poder aprovecharnos de esta nueva caracter铆stica es obvio que tenemos que agregar el campo version en nuestra entidad. En el caso de que estemos utilizando tablas tipadas tenemos que a帽adir directamente el campo __version o utilizar el atributo JsonProperty.

En nuestro ejemplo, agregamos el nuevo campo en la clase Person.

public class Person
{
    public String Id { get; set; }

    public String Name { get; set; }

    public String Phone { get; set; }

    public String Comments { get; set; }

    [JsonProperty(PropertyName = "__version")]
    public string Version { set; get; }
}

A partir de la versi贸n 1.1 del SDK podemos utilizar el atributo Version del namespace Miscrosoft.WindowsAzure.MobileServices. Esto significa que si agregamos la referencia a trav茅s de la opci贸n Agregar referencia a servicio de Visual Studio no podremos utilizar este atributo, sino que tenemos que a帽adir manualmente la referencia al paquete Nuget.

public class Person
{
    public String Id { get; set; }

    public String Name { get; set; }

    public String Phone { get; set; }

    public String Comments { get; set; }

    [Version]
    public String Version { get; set; }
}

Capturando MobileServicePreconditionFailedException

Al capturar la excepci贸n en cliente podremos decidir qu茅 acci贸n realizar: sobrescribir los datos de servidor, no realizar ning煤n cambio, o combinar los datos. En el caso de que queramos sobrescribir los datos o combinarlos tendremos que actualizar el valor del campo __version con el valor actual del servidor. Esto lo podemos realizar utilizando el valor de la propiedad Item de la excepci贸n, que contiene los valores de servidor.

private async Task InsertOrUpdatePerson(Person person)
{
    Exception exception = null;

    try
    {
        if (String.IsNullOrEmpty(person.Id))
        {
            await personTable.InsertAsync(person);
        }
        else
        {
            await personTable.UpdateAsync(person);
        }

        await new MessageDialog("Person saved succesfully!").ShowAsync();

    }
    catch (Exception ex)
    {
        exception = ex;
    }

    if (exception != null)
    {
        if (exception is MobileServicePreconditionFailedException)
        {
            var serverRecord = ((MobileServicePreconditionFailedException<Person>)exception).Item;

            await ResolveConflict(currentPerson, serverRecord, exception.Message);
        }
        else
        {
            await new MessageDialog(exception.Message).ShowAsync();
        }
    }
}

private async Task ResolveConflict(Person localItem, Person serverItem, string message)
{
    MessageDialog msgDialog = new MessageDialog(message, "Resolve conflict");

    UICommand localBtn = new UICommand("Commit Local Text");
    UICommand serverBtn = new UICommand("Leave Server Text");

    msgDialog.Commands.Add(localBtn);
    msgDialog.Commands.Add(serverBtn);

    localBtn.Invoked = async (IUICommand command) =>
    {
        // Get server value
        localItem.Version = serverItem.Version;

        await InsertOrUpdatePerson(localItem);
    };

    serverBtn.Invoked = async (IUICommand command) =>
    {
        await RefreshPeople();
    };

    await msgDialog.ShowAsync();
}

Naturalmente, es posible que nos encontremos ante un nuevo conflicto de concurrencia cuando guardemos de nuevo la entidad, as铆 que tenemos que comprobar que no se produzca ning煤n conflicto, por eso estamos llamando al mismo m茅todo (InsertOrUpdatePerson) para guardar la entidad.

Detectar conflictos en servidor

Tambi茅n es posible detectar el conflicto desde el lado de servidor y poder llevar a cabo acciones sin tener que devolver un error al cliente y que sea el usuario quien tenga que tomar una decisi贸n.

Por defecto, la funci贸n execute devuelve la respuesta autom谩ticamente, pero podemos pasar par谩metros opcionales para sobrescribir este comportamiento. Hasta ahora dispon铆amos de los par谩metros succes y error. Ahora, adem谩s, tenemos el par谩metro conflict que podemos utilizar para pasar una funci贸n callback que se ejecutar谩 cuando ocurra un conflicto de concurrencia. Esta funci贸n nos permitir谩 modificar los resultados antes de escribir la respuesta.

En nuestro ejemplo vamos poner una condici贸n para que en caso de conflicto, se compruebe si no se han modificado el nombre y el tel茅fono y en caso afirmativo permitiremos la transacci贸n, y en caso contrario devolveremos un error.

function update(item, user, request) {
    request.execute({ 
        conflict: function (serverRecord) {
            // Only committing changes if name and phone are not changed.
            if (serverRecord.Name === item.Name && serverRecord.Phone === item.Phone) {
                request.execute();
            }
            else {
                request.respond(statusCodes.FORBIDDEN, 'The name or the phone have changed.');
            }
        }
    }); 
}

En este ejemplo si se produce un conflicto y el nombre o el tel茅fono cambian se devolver谩 un error 403 que se traduce en una excepci贸n MobileServiceInvalidOperationException. Si queremos devolver un error de concurrencia, tenemos que devolver un error 412 Precondition failed. En este caso tenemos que utilizar el c贸digo de error ya que el objeto statusCodes no contiene ning煤n miembro para este c贸digo de error.

function update(item, user, request) {
    request.execute({ 
        conflict: function (serverRecord) {
            // Only committing changes if name and phone are not changed.
            if (serverRecord.Name === item.Name && serverRecord.Phone === item.Phone) {
                request.execute();
            }
            else {
                request.respond(412, 'The name or the phone have changed. Please resolve the conflict.');
            }
        }
    }); 
}

Sin embargo, esta soluci贸n presenta un problema y es que si provocamos un conflicto e intentamos mantener los datos locales, al obtener el valor de la versi贸n de servidor obtendremos un error de referencia nula ya que la excepci贸n no contiene el elemento 脥tem con los valores de servidor.

Si echamos un vistazo al c贸digo del m茅todo UpdateSync en la clase MobileServiceTable del SDK, vemos que el objeto que se utiliza para establecer el valor de la propiedad 脥tem se obtiene directamente del contenido del mensaje. Lo vemos en la llamada al m茅todo ParseContent.

public async Task<Token> UpdateAsync(JObject instance, IDictionary<String, String> parameters)
{
    JToken jTokens;
    if (instance == null)
    {
        throw new ArgumentNullException("instance");
    }
    MobileServiceInvalidOperationException mobileServiceInvalidOperationException = null;
    Object id = MobileServiceSerializer.GetId(instance, false, false);
    String str = null;
    if (!MobileServiceSerializer.IsIntegerId(id))
    {
        instance = MobileServiceTable.RemoveSystemProperties(instance, out str);
    }
    parameters = MobileServiceTable.AddSystemProperties(this.SystemProperties, parameters);
    try
    {
        JToken jTokens1 = await this.StorageContext.UpdateAsync(this.TableName, id, instance, str, parameters);
        jTokens = jTokens1;
        return jTokens;
    }
    catch (MobileServiceInvalidOperationException mobileServiceInvalidOperationException2)
    {
        MobileServiceInvalidOperationException mobileServiceInvalidOperationException1 = mobileServiceInvalidOperationException2;
        if (mobileServiceInvalidOperationException1.Response != null && mobileServiceInvalidOperationException1.Response.get_StatusCode() != 412)
        {
            throw;
        }
        mobileServiceInvalidOperationException = mobileServiceInvalidOperationException1;
    }
    JToken jTokens2 = await MobileServiceTable.ParseContent(mobileServiceInvalidOperationException.Response);
    throw new MobileServicePreconditionFailedException(mobileServiceInvalidOperationException, jTokens2);
    return jTokens;
}

As铆 que si queremos devolver el objeto de servidor, simplemente tenemos que pasar el objeto de servidor (serverRecord) como cuerpo del mensaje, en lugar del mensaje de error. El script de la funci贸n Update queda as铆.

function update(item, user, request) {
        request.execute({ 
        conflict: function (serverRecord) {
            // Only committing changes if name and phone are not changed.
            if (serverRecord.Name === item.Name && serverRecord.Phone === item.Phone) {
                request.execute();
            }
            else {
                request.respond(412, serverRecord);
            }
        }
    }); 
}

Probar control de concurrencia con Windows Store apps

Para probar el control de concurrencia con aplicaciones para la Windows Store, podemos seguir las instrucciones que aparecen en la documentaci贸n de MSDN, en la que se nos indica c贸mo crear el paquete e instalarlo en otra m谩quina. Pero si no disponemos de otra m谩quina donde hacer el despliegue, una alternativa es hacer una copia de la soluci贸n y modificar el Package Name del manifest de la aplicaci贸n. Otra forma de probarlo es modificar directamente el valor de la propiedad __version. En el ejemplo que he utilizado para esta entrada he dejado el campo versi贸n visible y editable para que se pueda modificar y simular un conflicto de concurrencia.

Tablas sin propiedades de sistema

Para finalizar, quiero hacer un 煤ltimo apunte en referencia a las nuevas propiedades de sistema. Ahora todas las tablas que creamos en Mobile Services incluyen los tres nuevos campos, si no vamos a hacer uso el control de concurrencia ni necesitamos los otros dos campos (createdAt y updatedAt), podemos crear la tabla sin estas columnas utilizando la l铆nea de comandos.

>azure account download
    
>azure account import <path-to-settings-file>.publishsettings
    
>azure mobile table create --integerId <service-name> <table-name>;

Este comando adem谩s de no agregar las columnas __createdAt, __updateAt, y __version, generar谩 la columna id de tipo entero en lugar de string.

Referencias

Handling Database Write Conflicts
Mobile Services Concepts: Create a table
Accessing optimistic concurrency features in Azure Mobile Services client SDKs
Mobile Services server script reference
Automate mobile services with command-line tools