Sincronización de Datos

Por simplicidad, en este capítulo, se establece la siguiente terminología: cuando se hable del cliente se está refiriendo al proyecto desconectado y del servidor, al proyecto en línea.

La sincronización de datos permite que las entidades definidas en el servidor se repliquen en el cliente y que las creadas y modificadas por el cliente se transfieran a la aplicación en línea, permitiendo así que se propaguen al resto de los proyectos desconectados.

Sin embargo existen diversas situaciones en las cuales se pueden presentar conflictos entre las versiones de una misma entidad:

  • Cuando una entidad se modifica tanto en el cliente como en el servidor
  • Cuando una entidad se modifica en dos clientes
  • Cuando dos o más entidades equivalentes se crean en dos clientes
  • Cuando se crea una entidad equivalente en el cliente y en el servidor
  • Cuando se elimina una entidad en el servidor referenciada en el cliente
  • Alguna combinación de los casos anteriores

La forma en que se resuelven estos conflictos debe ser abordada por el programador durante el diseño de la aplicación.

En esta tesina se optó por implementar una mecánica simple y extendible, que resuelve casos simples, en doff.contrib.offline.

Para implementar la sincronización se abordaron los siguientes ítems:

  • Integridad de la sincronización (orden de transferencia de los modelos, transacciones)
  • Transporte de datos
  • Detección de cambios en instancias en el servidor
  • Detección de cambios en el cliente

A continuación se realiza un análisis de los componentes intervinientes en la sincronización de datos como el transporte de datos, la codificación y el sistema de comunicación (RPC). Además se abordan algunos extras a la presente tesina como la resolución de conflictos.

Transporte de Datos

El primer aspecto a considerar es el transporte de datos. Las bases de datos relacionales utilizan el lenguaje SQL como estándar para la definición de su estructura y manipulación de datos. Django provee un ORM que encapsula el acceso al RDBMS y brinda una capa de abstracción en el lenguaje Python. Doff realiza lo propio en el lenguaje JavaScript, apoyándose en las extensiones provistas por Protopy. Gracias al trabajo del sitio remoto, mediante el registro de modelos, se obtiene consistencia entre las definiciones de alto nivel del servidor con las del cliente, por lo cual se optó por realizar el transporte utilizando los mecanismos de alto nivel disponibles en Django implementando el soporte necesario en Doff.

Django provee un módulo llamado serializers que brinda un conjunto de serializadores (marshalling) de instancias de QuerySet. Un serializador se encarga de serializar las instancias en texto con algún formato. Los formatos de salida provistos por Django son: XML, JSON y Python. Cuando un serializador trabaja sobre un QuerySet almacena información sobre el modelo para poder luego recuperar las instancias mediante un deserializador.

Se optó por el serializador basado en JSON, debido a que presenta ventajas a la hora de depuración, aunque conlleva mayor tiempo de procesamiento en conjuntos grandes de datos.

Los serializadores, en Django, son obtenidos a través de un factory de clases con la signatura get_serializer(nombre). Por ejemplo, para el siguiente modelo:

class Pais(models.Model):
    nombre = models.CharField(max_length = 45)
    simbolo_moneda = models.CharField(max_length = 8, default = '$')

    def __unicode__(self):
        return self.nombre

    class Meta:
        verbose_name = u"País"
        verbose_name_plural = u"Paises"

class Provincia(models.Model):
    pais = models.ForeignKey(Pais)
    nombre = models.CharField(max_length = 140)

    def __unicode__(self):
        return self.nombre

se puede serializar en JSON de la siguiente forma:

from core.models import Provincia # Importanción de modelos
# Importación del módulo de serialización
>>> from django.core import serializers
# Obtención de la clase de serialización en JSON
>>> SerializadorCls = serializers.get_serializer('json')
# Creación de una instancia del serializador
>>> serializador = SerializadorCls()

# Serialización
>>> plano = serializador.serialize(Provincia.objects.all())
# Los datos son texto plano
>>> plano

    '[
        {   "pk": 1,
            "model": "core.provincia",
            "fields":   {
                "pais": 1,
                "nombre": "Buenos Aires"
            }
        },
        {
            "pk": 2,
            "model": "core.provincia",
            "fields": {
                "pais": 1,
                "nombre": "C\\u00f3rdoba"
            }
        }
     ]'

# Para recuperar los datos, se obtiene una función deserializador
# que retorna instancias guardables

>>> deserializador_func = serializers.get_desserializer('json')

# Invocación al deserializador sobre el texto serializado
>>> for obj in deserializador_func(plano):
...     # Guardado de la isntancia
...     obj.save()

Como se mencionó anteriormente, los modelos expuestos al cliente se encuentran en registrados en el sitio remoto mediante un proxy. El manager definido en este proxy es el que se utilizará a la hora de serializar los datos para su envío al cliente. Por defecto, si no se define un manager, se utiliza _default_manager que es un alias de objects.

Se implementó sobre Doff el módulo de serialización para poder recuperar los datos y generar las instancias correspondientes en el cliente.

Si bien con la utilización de los serializadores se tiene un mecanismo extensible y simple de transporte de datos, se debe tener en cuenta que en el caso de contar con relaciones en los modelos, los datos serializados deben ser enviados en orden para no violar la integridad referencial, provocando una falla en el método save() en la deserialización. Desde el punto de vista de las relaciones, los modelos conforman una jerarquía de árbol con múltiples raíces. Gracias a la meta-información que brinda las definiciones de alto nivel, el orden de precedencia fue calculado de manera sencilla.

Versionado de Modelos

Mediante los serializadores se puede realizar una transferencia de datos entre el cliente y el servidor, pero no se ha expuesto aún un mecanismo para controlar los cambios realizados sobre las instancias tanto en el proyecto en línea como en el proyecto basado en Doff.

Para realizar el análisis de la información necesaria sobre los cambios de las instancias, se planteó un escenario hipotético que se describe a continuación:

Existe un proyecto en línea, que cuenta con un sitio remoto publicado. Existen una o más instancias del proyecto creadas a partir del sitio remoto. Los clientes poseen conexión ocasional, durante la cual realizan la tarea de sincronización enviando y recibiendo los cambios del sitio remoto. Ante un conflicto de datos insalvable de manera automática mediante las clases de middleware de sincronización, se puede definir que política adoptar: prevalencia de los datos del servidor, prevalencia de los datos del cliente, intervención del usuario u otra.

Los cambios en las instancias ocurren ante los eventos de creación, modificación y eliminación. Sin embargo, estos eventos se producen tanto en la aplicación del cliente como en la aplicación en línea, por lo que se realiza un análisis por separado de cada una de estas situaciones:

  • Creación de una entidad

    Ocurre cuando se genera una instancia de alguna entidad del ORM y se invoca el método save().

    • En el servidor

      Se debe tener un registro de que la entidad ha sido creada. El cliente debe copiar la nueva instancia en la próxima sincronización.

    • En el cliente

      Se crea la entidad con una clave local del cliente. Durante la sincronización, la entidad será creada en el proyecto en línea, devolviendo al cliente el identificador del servidor.

  • Modificación de una entidad

    Ocurre cuando se recupera una instancia mediante un manager y se modifican sus valores, llamando posteriormente al método save().

    • En el servidor

      Se debe tener un registro de que la entidad ha sido modificada. El cliente debe actualizar la instancia en la próxima sincronización.

    • En el cliente

      Si la entidad se creó en el cliente y nunca fue sincronizada, sigue siendo nueva para el servidor. En cambio, si la entidad fue sincronizada con el servidor se debe tener un registro de que la entidad ha sido modificada.

  • Eliminación de una entidad

    Ocurre cuando se invoca delete() sobre una entidad recuperada mediante un manager, o cuando se invoca directamente sobre el manager.

    • En el servidor

      Se elimina la instancia en el servidor, generando un registro de la baja.

    • En el cliente

      La eliminación en un cliente debe provocar una baja lógica en el servidor.

Del breve análisis expuesto se deduce que se debe almacenar información extra referente al estado de una instancia.

Junction [JunctionDocsSync09] es un framework de desarrollo de aplicaciones web que abordó el problema de sincronización agregando una serie de campos en las entidades:

- id          integer primary key autoincrement
- created_at  datetime
- updated_at  datetime
- active      integer
- version     integer
- id_start    integer
- id_start_db varchar(40)
- synced_at   datetime

En el caso de Junction, la información de versionado se almacena tanto en el servidor como en el cliente. Aplicar este enfoque sobre un proyecto existente de Django no es factible debido a que involucra modificar todas los modelos preexistentes.

Se optó por el agregado de información de manera asimétrica: en el cliente las entidades se crean con campos extras, mientras que en el servidor se utilizan relaciones genéricas.

Información de Sincronización en el Cliente

La información sobre sincronización en el cliente se basó en parte en los campos que propone Junction, agregándoselos a cada entidad, pero se modeló la sincronización como entidad separada.

Se creó la aplicación genérica doff.contrib.offline, donde se definió el modelo SyncLog. Cada vez que el cliente realiza una sincronización con el servidor, se almacena la fecha sobre una instancia de esta entidad, de manera que durante la sincronización siguiente, sólo se trabaje con los modelos afectados en el intervalo de tiempo transcurrido.

Los datos que se agregaron a cada modelo del cliente fueron:

  • server_pk

    Es la clave de la entidad en el servidor, si la entidad se creó en el cliente este campo es NULL.

  • active

    Indica la baja lógica en el servidor. Por defecto toma el valor true.

  • sync_log

    Referencia la entidad de SyncLog en la cual la instancia fue sincronizada.

  • status

    Este campo se creó con el objeto de indicar el estado de una entidad en el cliente. Sus valores posibles son los siguientes:

    • "C" (created)

      La entidad se creó en el cliente mediante el método save(). Los atributos sync_log y server_pk son NULL.

    • "S" (synchronized)

      Indica que la entidad se encuentra sincronizada con el servidor. En este caso, los atributos sync_log y server_pk tienen sus valores correspondientes.

    • "M" (modified)

      La entidad sincronizada ha sido modificada en el cliente. Ante una sincronización se actualiza la referencia de sync_log y se vuelve al estado sincronizado ("S").

    • "D" (deleted)

      Indica que la entidad fue borrada en cliente. Ante una sincronización el servidor pasa al estado sincronizado ("S") y el atributo active pasa a false.

[JunctionDocsSync09]Steve Yen, TrimPath Junction Syncrhonization, http://trimpath.googlecode.com/svn/trunk/junction_docs/files/junction_doc_sync-txt.html

Información de Sincronización en el Servidor

Se mencionó en el capítulo en el cual se introdujo Django, que el ORM de este framework brinda lo que se conoce como el framework de ContentType (no confundir con la cabecera HTTP), que consiste en una serie de Fields del ORM que permiten crear relaciones genéricas. Además, el framework posee un sistema de señales que permiten el conexionado de eventos del ORM a funciones.

Con el objeto de registrar los cambios sobre entidades en el servidor, se decidió crear un modelo denominado SyncData. Una instancia de éste se crea ante los eventos de guardado (modificación) y eliminación de las entidades.

De esta manera sólo las entidades que forman parte del proyecto desconectado son “vigiladas” para detectar cambios; cuando éstos se producen se registra la fecha. Cada vez que se agrega un modelo a un sitio remoto, internamente también se registran manejadores de eventos ante las señales de guardado (post_save) y eliminación (post_delete).

Protocolo de Sincronización

El proceso de sincronización entre ORMs conlleva la transferencia de datos con una estructura y en un orden para garantizar la integridad referencial de la bases de datos subyacentes. Por ejemplo, no se puede crear una entidad débil sin antes crear la entidad a la cual referencia.

Para realizar esta tarea se eligió JSONRPC, en vez de HTTP puro, como mecanismo de comunicación entre el cliente y el servidor. Esta decisión se tomó debido a que la codificación de parámetros que ofrece HTTP, application/x-www-form-urlencoded y multipart/form-data, está orientada al pasaje de texto sin estructura [W3CFormEncoding09].

JSONRPC es un protocolo de llamado a procedimiento remoto muy similar a XMLRPC, pero que utiliza JSON para codificación de los mensajes, contribuyendo a una fácil lectura de la información enviada por parte del programador durante la depuración y generalmente enviando una menor cantidad de caracteres [WikiJSONRPC09].

[WikiJSONRPC09]Wikipedia, JSONRPC, último acceso Noviembre de 2009, http://en.wikipedia.org/wiki/JSON-RPC

Se implementó en la URL /sync de la clase RemoteSite un manejador de llamadas JSONRPC. Por simplicidad y consistencia con el decorador expose se creó el decorador jsonrpc. Por ejemplo, un método expuesto en el sitio remoto se implementa de la siguiente manera:

@jsonrpc
def echo(self, value):
    return value

Una vez establecido el mecanismo de comunicación, fue necesario definir los métodos a publicar. Basados en las primitivas de sincronización de los sistemas de control de versiones distribuidos como Git [GitDocs09], Mercurial [MercurialDocs09] o Bazaar [BazaarDocs09], se crearon los métodos [*] pull y push en el servidor:

  • pull(sync_log = None)

    Retorna los cambios del servidor en un volcado de datos JSON de los modelos registrados en el sitio remoto.

    Recibe opcionalmente un agumento sync_log, en el cual el cliente debe enviar la última instancia de la clase SyncLog con el objeto de filtrar los cambios realizados sobre los modelos desde la última sincronización del cliente.

    En la sincronización inicial, no existen instancias de SyncLog en el cliente para enviar, por lo que este argumento es nulo. En este caso, el método RPC devuelve un volcado completo de los modelos registrados.

  • push(received)

    Recibe en received un diccionario con las claves:

    • "deleted"

      Modelos que han sido eliminados. Es un diccionario, con las siguientes claves:

      • "models"

        Nombres de los modelos, en el orden que deben ser eliminados. Desde las entidades débiles hacia las fuertes.

      • "objects"

        Es un diccionario, donde las claves son los nombres de los modelos y los valores son los objetos serializados.

    • "modified"

      Modelos que han sido modificados en el cliente. Es un diccionario similar al de "deleted".

    • "created"

      Modelos que han sido creados en el cliente. Es un diccionario similar al de "deleted" y "modified".

    • "sync_log"

      Diccionario de instancias SyncLog correspondientes a cada modelo. Por cada modelo que figure en "deleted", "modified" o "created", debe haber un par clave-valor en este diccionario.

    El siguiente gráfico muestra los argumentos enviados en el método push():

    _dot/push_return.pdf

    Argumentos de push()

Ambas operaciones tienen carácter atómico, ya que se ejecutan dentro de una transacción de la base de datos.

En cuanto al cliente, se implementó una aplicación doff.contrib.offline, que se instala por defecto en settings.js con la ejecución del comando start_remotesite.

Dentro del modulo doff.contrib.offline.handler se creó la clase SyncHandler con los métodos pull() y push(), estos métodos se encargan de realizar las llamadas RPC:

  • pull()

Se encarga de obtener la última instancia de SyncLog e invocar al método pull del servidor.

Los resultados de este método son: los objetos recibidos del servidor y la información de sincronización.

  • push()

    Obtiene, para todos los modelos del proyecto desconectado, las instancias creadas/modificadas/eliminadas con sus correspondientes instancias de SyncLog y serializarlas. Por último invoca al método push del servidor.

    El resultado de este método es una lista con los siguientes valores:

    • need_pull

      Indica que el cliente no cuenta con la última versión de los datos y que debe realizar un nuevo pull() antes de realizar un push().

    • chunked

      Indica que sólo una parte de los modelos enviados pudo ser persistida en el servidor.

    • deleted

      Indica qué modelos pudieron ser eliminados.

    • modified

      Indica qué modelos pudieron ser modificados.

    • created

      Indica qué modelos pudieron ser creados.

    • sync_log_data

      En el caso de que need_pull sea False, indica la nueva fecha con la cual se deben actualizar las referencias a SyncLog de los modelos afectados por el push().

[W3CFormEncoding09]Dave Raggett, Arnaud Le Hors, Ian Jacobs, Especificación de codificación de contenidos de formularios en HTML 4.1, ultimo acceso Noviembre de 2009, http://www.w3.org/TR/html401/interact/forms.html#form-content-type
[GitDocs09]Sistema de Control de Versiones Git, último acceso Noviembre de 2009, http://git-scm.com/
[MercurialDocs09]Sistema de Control de Versiones Mercurial, último acceso Noviembre de 2009, http://mercurial.selenic.com/guide/
[BazaarDocs09]Martin Pool Sitio Oficila de Sistema de Control de Versiones Bazaar, último accseo Noviembre 2009, http://bazaar-vcs.org/en/
[*]No confundir la definición de método con remoting de objetos o RMI.

Para realizar sincronización basada en estos métodos se genera una instancia de la clase y se realiza la operación deseada:

>>> require('doff.contrib.offline.handler', 'SyncLog');
>>> var sl = new SyncLog();
// Para recuperar cambios del servidor
>>> var [received,
            sync_log_data ] = sl.pull();
    // Para enviar cambios hacia el servidor
>>> var [need_pull,
            chunked,
            created,
            modified,
            created,
            sync_log_data ] = sl.push();

Problemas de Sincronización

Cuando un cliente desconectado genera instancias de clases relacionadas, los identificadores (campos id) son establecidos por la base de datos del cliente. Este problema se da cuando, por ejemplo, se tiene una clase Pedido relacionada con ItemPedido y el cliente crea pedidos con ítems. Las instancias tiene status en "C" (creado) y el valor server_pk en NULL. Si bien la relación tiene sentido en el contexto del cliente, durante la ejecución de push() el servidor no puede resolver las relaciones debido a que ignora los campos id del cliente.

El cliente evalúa si ocurre esta situación verificando si la cantidad de modelos devueltos es diferente a la cantidad de modelos enviados. De ser así, se está ante una sincronización por partes o chunked. Debido a que el método push() actualiza los campos server_pk de las instancias que pudo crear en el servidor, una nueva invocación de push() sólo enviará las entidades no sincronizadas. Por lo tanto, se deben realizar tantas invocaciones como niveles de relaciones se tengan sin resolver. Es importante aclarar que la cantidad de invocaciones no crece con la cantidad de instancias, sino con las profundidad de relaciones.

El siguiente gráfico ejemplifica el cambio de las entidades en el cliente cuando ocurre la sincronización por partes:

_dot/update_rationale.pdf

Resolución de referencias en el cliente por partes.

Otro problema que puede ocurrir durante la sincronización es que el cliente no posea la versión más actual del estado del modelo. Este caso se detecta en el servidor mediante comparación de fechas de los sync_log enviados en el push() (análisis de las fechas de la lista received["sync_log"]). Ocurre cuando se realizan cambios en el servidor desde la última sincronización del cliente.

Para resolver los problemas anteriormente descriptos se creó un nuevo método que envuelve las llamadas a push() y pull(). Se lo denominó update() y es también un método del SyncHandler. Su algoritmo es el siguiente:

  1. Traer cambios desde el servidor, mediante el llamado a pull().

  2. Ejecutar push() si hay cambios a ser enviados (determinados de acuerdo al valor del campo status de cada modelo):

    1. Si chunked es falso, la actualización ha sido completa y finaliza actualizando el estado y relación con SyncLog de los modelos intervinientes.
    2. En caso contrario, actualiza los server_pk y vuelve a enviar los modelos que no se han podido crear, debido a que eran débiles y tenían su server_ok en NULL. Eventualmente chunked pasa al valor false y la actualización es exitosa.

Cuando una entidad es eliminada en el cliente y se ejecuta push(), la bandera active toma le valor false. Cuando el programador considere oportuna la eliminación de las entidades inactivas puede utilizar el método purge() de SyncHandler, que sólo elimina una entidad si no posee relaciones o todos los campos relacionados poseen su bandera active en false.

El campo status es el que determina qué modelos deben ser sincronizados.

La ejecución de los métodos mencionados genera cambios de estado. El siguiente diagrama ejemplifica este hecho:

_dot/estados_sincronziacion_cliente.pdf

Estados de sincronización de los modelos en el cliente.

Resolución de Conflictos

Durante la ejecución de pull() y push() pueden ocurrir conflictos de consistencia (por ejemplo, cuando los datos fueron modificados y/o eliminados en ambos extremos, o cuando las entidades creadas generan errores de unicidad).

Para tratar estos problemas, se crearon middlwares de sincronización. A diferencia del resto de los middlewares de Django y Doff, existe un solo middleware de este tipo por proyecto desconectado.

Este middleware es una clase que debe heredar de SyncMiddleware y estar referenciada bajo el nombre SYNC_MIDDLEWARE_CLASS en settings.js.

Los métodos de este middleware que el programador puede sobrecargar son los siguientes:

  • resolve_unique(local_object, remote_object)

    Llamado cuando se genera un error de unicidad en el servidor. Este método es invocado con los objetos local y remoto en conflicto.

  • resolve_localDeleteRemoteModified(local_object, remote_object)

    Llamado cuando se ha borrado una instancia localmente, que en el servidor se ha modificado. Sus argumentos son el objeto local y el objeto remoto.

  • resolve_localModifiedRemoteModified(local_object, remote_object)

    Llamado cuando se ha modificado una instancia localmente y que en el servidor también ha sido modificada. Sus argumentos son el objeto local y el objeto remoto.

  • resolve_localModifiedRemoteDeleted(local_object, remote_object)

    Llamado cuando se ha modificado una instancia localmente, que en el servidor se ha eliminado. Sus argumentos son el objeto local y el objeto remoto.

  • before_push(data)

    Llamado previo a la ejecución de push(), con los datos serializados como argumentos.

  • before_pull(data)

    Llamado previo a la ejecución de pull(), con los datos serializados como argumentos.

  • after_push(data)

    Llamado posteriormente a la ejecución de push(), con los datos serializados como argumentos.

  • after_pull(data)

    Llamado posteriormente a la ejecución de pull(), con los datos serializados como argumentos.

Se ofrecen al programador las siguientes clases con comportamiento por defecto para resolver los conflictos:

  • ClientWinsSyncMiddleware

    Prevalece la información del cliente.

  • ServerWinsSyncMiddleware

    Prevalece la información del servidor.

Este tipo de middlewares es opcional.

Consideraciones Sobre Sincronización

Doff provee un mecanismo básico de sincronización adecuado para pequeñas aplicaciones, que permite al programador contar con las herramientas para transporte, comunicación RPC y control de cambios. Debido a que no existe una política universal para todas las aplicaciones, y como Doff se trata de un framework extensible, se pueden implementar mecanismos más sofisticados en base a las herramientas expuestas.