Skip to main content

🏗️ Arquitectura de Sincronización Offline-First entre App Móvil y Backend

🎯 Objetivo​

Diseñar un proceso completo y robusto para la sincronización de datos (catálogos y tablas transaccionales) entre una aplicación móvil y un sistema backend con soporte Offline-First.


🧩 Componentes de la Arquitectura​

📱 App Móvil​

  • DB Local: (SQLite/Realm) con catálogos, transacciones y tablas de control (pending_outbox, sync_state, oplog).
  • Outbox: registra todas las operaciones CRUD realizadas sin conexión.
  • Motor de Sync: detecta conectividad, maneja colas y aplica deltas desde el backend.
  • Conflict Resolver: gestiona conflictos (LWW o cliente-gana).
  • Manejador de Push: escucha notificaciones silenciosas y dispara sincronizaciones automáticas.

🌐 API Backend​

  • Endpoints de sincronización (batch e idempotentes).
  • Módulo de catálogos con deltas y versionado.
  • Resolución de conflictos y validación de negocio.
  • Control de dispositivos (tokens, versiones, sincronización remota).

☁️ Servicio de Push​

  • Usa FCM (Android) o APNs (iOS).
  • Envía notificaciones silenciosas (content-available=1) para iniciar sincronizaciones sin alertar al usuario.

🗄️ Base de Datos Servidor​

  • Tablas maestras y operacionales con campos updated_at, version, o etag.
  • Control de versiones para catálogos y journaling para generar deltas.

🖥️ Servicio de Gestión de Dispositivos​

  • Almacena device_id, push_token, app_version, last_seen, platform.
  • Permite sincronizaciones dirigidas a segmentos de usuarios o zonas específicas.

⚙️ Estrategias Técnicas​

1️⃣ Detección de Cambios (Cliente → Servidor)​

Tabla pending_outbox con:

  • id (UUID), op (CREATE/UPDATE/DELETE), entity, entity_id, payload, idempotency_key, status, retry_count, created_at_local.
  • La app siempre escribe localmente primero, luego registra en pending_outbox.
  • El motor de sync procesa los registros PENDING en lotes.

2️⃣ Actualización de Catálogos (Servidor → Cliente)​

  • Modelo “delta by timestamp/version”:
    • El cliente guarda last_catalog_sync_at por catálogo.
    • El backend expone /catalogs/{name}/delta?since=timestamp.
    • Devuelve upserts[] y tombstones[] con updated_at o version.

3️⃣ Manejo de Lotes​

  • Tamaños configurables (50–200 items).
  • Reintentos con backoff exponencial y jitter.
  • Endpoint batch idempotente con idempotency_key persistido en backend (TTL 14 días).

4️⃣ Resolución de Conflictos​

Tipo de datoEstrategiaRegla
CatálogosLWW (Last Write Wins)El servidor gana
Transacciones nuevasCliente ganaSe aceptan si no violan reglas de negocio
Updates sobre misma entidadOptimistic ConcurrencyIf-Match: <etag> o version

5️⃣ Atomicidad​

  • Transacción por item dentro del batch.
  • Éxito parcial permitido, respuesta con matriz de resultados.
  • Modo opcional atomic=true para lotes que deben fallar todos o ninguno.

6️⃣ Seguridad​

  • Autenticación OAuth2.0/JWT.
  • Conexión segura TLS.
  • Límite de payload (512KB–2MB) y compresión GZIP.
  • Trazas con idempotency_key, device_id, sync_session_id.

🧠 Diagrama de Flujo – Lógica del Cliente (Offline-First)​


🔁 Diagrama de Secuencia – Sincronización de Transacciones​


📡 Diagrama de Secuencia – Sincronización Forzada (Push Silenciosa)​


🗃️ Esquema de Base de Datos Local​

CREATE TABLE pending_outbox (
id TEXT PRIMARY KEY,
op TEXT NOT NULL,
entity TEXT NOT NULL,
entity_id TEXT NOT NULL,
payload TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
created_at_local TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'PENDING',
retry_count INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE sync_state (
key TEXT PRIMARY KEY,
last_sync_at TEXT,
last_version INTEGER
);

CREATE TABLE cat_products (
id TEXT PRIMARY KEY,
sku TEXT,
name TEXT,
price REAL,
updated_at TEXT,
version INTEGER
);

🧩 Endpoints REST Recomendados​

Transacciones​

POST /transactions/batch

Request:

{
"items": [
{
"idempotency_key": "uuid",
"op": "CREATE",
"entity": "order",
"client_id": "uuid",
"payload": { "field": "value" }
}
],
"device": { "id": "dev123", "app_version": "1.2.0" }
}

Response:

{
"results": [
{
"idempotency_key": "uuid",
"status": "OK",
"server_id": "srv-001",
"etag": "v2",
"message": "Success"
}
]
}

Catálogos​

GET /catalogs/{name}/delta?since=2025-01-01T00:00:00Z

Response:

{
"upserts": [{ "id": "p1", "name": "Leche Alpura", "price": 22.5 }],
"tombstones": [{ "id": "p2", "deleted_at": "2025-10-01T00:00:00Z" }],
"server_time": "2025-10-22T10:00:00Z",
"version": 15
}

Catálogos​

GET /catalogs/{name}/delta?since=2025-01-01T00:00:00Z

Response:

{
"upserts": [{ "id": "p1", "name": "Leche Alpura", "price": 22.5 }],
"tombstones": [{ "id": "p2", "deleted_at": "2025-10-01T00:00:00Z" }],
"server_time": "2025-10-22T10:00:00Z",
"version": 15
}

Sincronización​

POST /sync/ack

Body:

{ "device_id": "dev123", "catalog": "products", "version": 15 }

🔄 Pseudocódigo de Ciclo de Sincronización​

function syncNow():
if !isNetworkAvailable(): return

// 1) Transacciones -> Servidor
items = db.outbox.fetch(limit = adaptiveBatchSize())
if items.isEmpty(): goto catalogs

resp = api.post("/transactions/batch", {items})
for each r in resp.results:
if r.status == "OK":
db.outbox.markSent(r.idempotency_key)
else if r.status == "CONFLICT":
db.outbox.markFailed(r.idempotency_key)
else:
db.outbox.incrementRetry(r.idempotency_key)

// 2) Catálogos <- Servidor
catalogs:
for cat in enabledCatalogs():
since = db.sync_state.get(cat).last_sync_at
do:
delta = api.get("/catalogs/"+cat+"/delta?since="+since)
db.tx {
applyUpserts(cat, delta.upserts)
applyDeletes(cat, delta.tombstones)
db.sync_state.update(cat, delta.server_time)
}
while delta.next_cursor != null

📈 Políticas de Reintento​

  • Errores 5xx / red: backoff exponencial (2ⁿ segundos, máx 5 min).
  • Errores 4xx (409, 412): marcar FAILED, posible resolución manual.
  • Timeouts: reducir tamaño de lote dinámicamente.

✅ Decisiones de Diseño por Defecto​

AspectoPolítica
Conflictos CatálogosServidor gana (LWW)
Conflictos TransaccionesCliente gana (validación posterior)
AtomicidadPor item (no todo-o-nada)
Tamaño de lote50–200 items
Idempotencia (server)TTL 14 días
Sync forzadaSilent push segmentada por device_tag

📊 Métricas y Observabilidad​

  • Métricas: tamaño de lote, tasa de conflicto, duración de sync.
  • Logs: idempotency_key, device_id, sync_session_id.
  • Alertas: spikes de CONFLICT o latencias elevadas.

🕒 Sincronización de Catálogos Programada (una vez al día)​

Esta sección agrega un planificador diario para refrescar catálogos aunque no haya actividad del usuario, complementando la sincronización por eventos y por push silenciosa.

Objetivos​

  • Garantizar que los catálogos estén actualizados al menos 1 vez al día.
  • Desacoplar del uso activo (si el usuario no abre la app).
  • Respetar batería y datos: ventana con backoff, solo en red no medida si es posible, y tamaño de delta paginado.

Política sugerida​

  • Horario sugerido: ventana entre 02:00–04:00 hora local (configurable por feature flag).
  • Estrategia: “delta by timestamp/version” por catálogo (products, clients, prices, routes, etc.).
  • Stagger por dispositivo**:** aplicar un jitter aleatorio (±30 min) para evitar picos en el backend.
  • Condiciones: preferir Wi‑Fi, batería > 20% y dispositivo en reposo (configurable por plataforma).
  • Failover: si falla la corrida diaria, reintentar con backoff y, al tercer fallo, forzar un full sync del catálogo afectado fuera de pico (con límites de página).

Diagrama de Flujo — Planificador Diario (cliente)​

Secuencia — Corrida Programada (diaria)​

Ajustes de Endpoint sugeridos​

  • GET /catalogs/{name}/delta?since=ISO8601&cursor=...&pageSize=...
  • Respuestas con server_time, version, next_cursor, hints de paginación.

📈 Políticas de Reintento​

  • Errores 5xx / red: backoff exponencial (2ⁿ segundos, máx 5 min).
  • Errores 4xx (409, 412): marcar FAILED, posible resolución manual.
  • Timeouts: reducir tamaño de lote dinámicamente.

✅ Decisiones de Diseño por Defecto​

AspectoPolítica
Conflictos CatálogosServidor gana (LWW)
Conflictos TransaccionesCliente gana (validación posterior)
AtomicidadPor item (no todo-o-nada)
Tamaño de lote50–200 items
Idempotencia (server)TTL 14 días
Sync forzadaSilent push segmentada por device_tag

📊 Métricas y Observabilidad​

  • Métricas: tamaño de lote, tasa de conflicto, duración de sync.
  • Logs: idempotency_key, device_id, sync_session_id.
  • Alertas: spikes de CONFLICT o latencias elevadas.