Sincronización offline-first¶
El protocolo de sincronización es el corazón de WorkDone Sanitarios. Los dispositivos —la app móvil de los operarios y el kiosk Smiley de opinión del público— operan en lugares donde la red no es confiable (sótanos de aeropuertos, sanitarios de shoppings). El sistema está diseñado para que nada de eso importe: el dispositivo trabaja 100% offline, acumula eventos localmente y los sube cuando puede.
Esta página explica por qué offline-first, el ciclo de sync paso a paso, las garantías (idempotencia, deduplicación, resolución de conflictos) y las diferencias entre el sync del mobile y el del kiosk Smiley.
Contratos de referencia
El detalle de request/response está en el API. Las entidades involucradas, en el modelo de datos. El vocabulario, en el glosario.
Por qué offline-first¶
La realidad física manda: un operario tapea su tag NFC en un sanitario donde no hay señal. Si el sistema dependiera de la red en ese instante, perdería el dato. En cambio:
- El dispositivo genera el
client_uuidal momento de la acción (el tap), ANTES de cualquier I/O. Esa es la identidad del evento y la única garantía de idempotencia: PK local (Room) + UK server. - El timestamp del teléfono es la verdad temporal (
ts_local_device→inicio_ts/fin_ts). El server registra cuándo lo recibió, pero no recalcula cuándo ocurrió. - La cola local se drena cuando hay red, en orden cronológico.
Lo que esto garantiza:
- Nada se pierde: cada acción queda persistida localmente antes de cualquier intento de red.
- Nada se duplica: el
client_uuiddeduplica tanto en el cliente (OnConflictStrategy.IGNOREen Room) como en el server (UK). - El server siempre re-valida: roles, exclusividad de eventos, máquina de estados del tag. Nunca se confía en la validación del cliente.
Lo que el protocolo NO hace (a propósito)
No hace merge de conflictos en catálogos (en catálogos el server siempre gana), no usa websockets ni push (es pull-based), y no implementa compresión propia (la maneja HTTP).
El ciclo de sync paso a paso¶
El sync es un único request transaccional que combina pull (bajar deltas de catálogos) y push (subir la cola local). Dos reglas de orden son intocables:
- Push en orden cronológico (
ts_local_device ASC) — ambas colas (trabajos y NFC). El server confía en ese orden. - Los deltas se aplican ANTES que los resultados de push en el cliente.
sequenceDiagram
autonumber
participant D as Dispositivo (Room)
participant B as Backend
participant DB as MSSQL
Note over D: El operario tapea el tag offline.<br/>Se genera client_uuid + ts_local_device<br/>ANTES de cualquier I/O. Se persiste local.
Note over D: Hay red (apertura app, post-tap,<br/>WorkManager 15 min, recuperar red, login...)
D->>B: POST /api/v1/app/sync<br/>{last_sync, trabajos_pendientes,<br/>nfc_eventos_pendientes, device_now}
Note over B,DB: PULL — calcula deltas por updated_at<br/>de los 7 catálogos (incluye soft-deleted)
B->>DB: SELECT por watermark
DB-->>B: filas actualizadas + ids borrados
Note over B,DB: PUSH — procesa cada ítem en orden,<br/>re-valida todo, dedup por client_uuid
B->>DB: INSERT trabajos / TRABAJO_EVENTO<br/>+ transiciones NFC + alertas
DB-->>B: resultados por ítem
Note over B: Mide drift si vino device_now<br/>(|device_now − server_now|)
B-->>D: {server_ts, deltas,<br/>trabajos_procesados[], nfc_eventos_procesados[],<br/>config}
Note over D: 1. Aplica deltas (catálogos)<br/>2. Procesa resultados de push<br/>3. Purga de la cola lo confirmado
Disparadores del sync (mobile)¶
Abrir la app (last_sync > 5 min) · tras cada tap/acción NFC · WorkManager cada 15 min · al recuperar red · al loguearse · pull-to-refresh · "Sincronizar ahora".
Procesamiento server por trabajo (el orden importa)¶
El server procesa cada trabajo de la cola en este orden:
- Dedup: si el
client_uuidya está entap_descartado→RECHAZADO_REBOTE(idempotente, se consulta antes quetrabajo). Si ya está entrabajo→DUPLICADO_IGNORADO(devuelve el existente, no duplica eventos ni alertas). - Resolver tag (uuid normalizado): si no existe / EN_STOCK / BAJA → trabajo con
sanitario_id=NULL,modo=OFFLINE_PENDIENTE_RESOLUCION+ alertaTAG_DESCONOCIDO→ estadoTAG_DESCONOCIDO. - Sanitario inactivo/eliminado →
SANITARIO_INACTIVO, no se persiste (el cliente purga sin reintentar). - Operario inactivo → se acepta + warning + alerta
OPERARIO_INACTIVO_OFFLINE. - Toggle según rol (LIMPIEZA si OPERADOR, SUPERVISION si SUPERVISOR; ADMIN →
ROL_NO_PERMITIDO, no persiste):- sin trabajo abierto → INGRESO.
- abierto en el mismo sanitario → EGRESO (+ procesar eventos).
- abierto en otro sanitario → cerrar el anterior con EGRESO_CIERRE_AUTOMATICO (duración truncada, sin eventos, warning) + INGRESO nuevo.
- Eventos del EGRESO: validar existencia/activo,
roles_permitidos, exclusividad (es_exclusivo ⇒ único), mínimo 1; materializarTRABAJO_EVENTOcon snapshots; cadagenera_alertaproduce una alertaEVENTO_REPORTADO. - Drift > 5 min → warning.
- Race de
client_uuiden el INSERT → devolver el existente.
Limpieza válida y SLA
Solo una limpieza válida resetea el SLA: tipo LIMPIEZA cerrada, sin eventos que invaliden la limpieza y sin duracion_anomala. SUPERVISION nunca resetea. El cierre automático sí resetea, con warning.
Idempotencia, deduplicación y conflictos¶
Idempotencia y deduplicación¶
La idempotencia se ancla por completo en el client_uuid:
- Generado en el dispositivo antes de cualquier I/O, es PK local y UK server.
- Un reintento del mismo
client_uuiddevuelve siempre el mismo resultado: si ya está procesado,DUPLICADO_IGNORADOcon el trabajo existente; si fue un rebote,RECHAZADO_REBOTEdesdetap_descartado. - Esto hace que reintentar la cola sea seguro: la red puede cortar a mitad de un sync sin generar duplicados.
Resolución de conflictos NFC¶
Para la cola de eventos NFC (nfc_eventos_pendientes), el server procesa cada uno:
client_uuidduplicado →DUPLICADO_IGNORADO.- Normalizar el
uuid_tagy aplicar la acción víaTagNfcLifecycleService:- ALTA_STOCK: idempotente por uuid (existente →
OKcon el actual). - ASIGNACION: el tag debe estar EN_STOCK y el sanitario sin tag ASIGNADO; si no →
CONFLICTO_ASIGNACION(el primero en sincronizar gana; el delta detag_nfctrae la verdad). - DESASIGNACION / REASIGNACION: validación de máquina de estados →
TRANSICION_INVALIDAsi no aplica. - Rol del operario insuficiente para la gestión NFC →
ROL_INSUFICIENTE. - Fallo inesperado al procesar el evento →
ERROR.
- ALTA_STOCK: idempotente por uuid (existente →
- Toda transición OK persiste su
TAG_NFC_EVENTO.
Reacción del cliente ante la respuesta: OK/DUPLICADO → purgar de la cola. CONFLICTO/TRANSICION_INVALIDA → purgar, revertir el optimismo local con el delta y notificar al usuario. Error transitorio → backoff (mismo esquema de 8 intentos que los trabajos).
Casos especiales destacados¶
- Tag reasignado entre tap y sync: el trabajo se asocia al sanitario actual del tag (la realidad física manda) + warning.
- Olvido de salida: cierre automático truncado (por el server o por el scheduler de trabajos colgados).
- Conflicto de asignación NFC offline: dos devices asignan tags al mismo sanitario; el primero en sincronizar gana, el segundo recibe
CONFLICTO_ASIGNACIONy revierte. - Egreso corto accidental (doble-tap al salir): el server cierra el trabajo con
duracion_anomala=1(no resetea SLA) + alertaDURACION_ANOMALA; el registro queda auditable. - Rebote de ingreso: un segundo tap a menos de
rebote-ingreso-segdel EGRESO (mismo operario+sanitario+device) se interpreta como doble-tap, no como INGRESO nuevo →RECHAZADO_REBOTE, se registra entap_descartado, no crea trabajo fantasma. - Registro MANUAL (placa rota): el ítem trae
manual=true+sanitario_id+motivo_contingencia_codigosin tag; el server resuelve por id, exige motivo válido (si falta →MANUAL_INVALIDO), crea el trabajo conmodo_registro=MANUAL+ alertaTAG_DEFECTUOSO.
Mobile vs. kiosk Smiley¶
El kiosk Smiley tiene su propio endpoint de sync (POST /api/v1/smiley/sync), separado del de operarios. Comparte las reglas de oro (idempotencia por client_uuid, ts_local_device como verdad temporal, push en orden cronológico, deltas antes que push, el server re-valida todo), pero es mucho más simple.
| Aspecto | Mobile (/api/v1/app/sync) |
Kiosk Smiley (/api/v1/smiley/sync) |
|---|---|---|
| Autenticación | JWT del operario (ROLE_OPERARIO) |
api_key del device, headers X-Device-Uuid + X-Api-Key (ROLE_TERMINAL) |
| Qué sube (push) | trabajos_pendientes + nfc_eventos_pendientes |
opiniones_pendientes |
| Qué baja (pull) | 7 catálogos (deltas) | solo el catálogo motivo_opinion |
| Identidad del sanitario | va en el request (vía tag/sanitario) | la determina el server por el vínculo del device; la terminal no puede opinar por otro sanitario |
| Estados de push | 8 estados (OK, TAG_DESCONOCIDO, RECHAZADO_REBOTE, …) | solo OK y DUPLICADO_IGNORADO |
| Anti-abuso | rebote/drift/duración anómala | debounce (3 s) + colapso de ráfaga (5 / 30 s) |
El sync de opiniones¶
La terminal envía opiniones con valor ∈ POSITIVA | NEUTRA | NEGATIVA (enum ValorOpinion), motivo_codigo? opcional, más telemetría opcional (app_version, os_version, bateria_pct, red_tipo, lock_task_activo). El server responde con opiniones_procesadas (estado OK o DUPLICADO_IGNORADO, los únicos dos que emite), el delta de motivo_opinion, y un bloque config (último servicio, textos, logo, pin_tecnico_hash).
Anti-abuso silencioso en el kiosk
Los descartes por debounce o ráfaga también devuelven OK a propósito, y se persisten con descartada=true (auditables, no se borran). El cliente drena de la cola toda opinión cuyo estado sea OK o DUPLICADO_IGNORADO; si una opinión enviada no vuelve en opiniones_procesadas, queda en cola para reintentar.
Disparadores del sync Smiley: tras cada opinión (inmediato) · WorkManager cada 15 min · al recuperar red. El idle ("último servicio") se refresca aparte con GET /api/v1/smiley/estado cada 5 min mientras la terminal está visible.
Detalles internos del sync¶
Hasta acá vimos el sync desde el contrato (qué entra, qué sale, qué garantías da). Esta sección baja un nivel: cómo lo implementa el server por dentro. Es material para debuggear —entender por qué una query es cara, por qué un trabajo abortó el batch pero un evento NFC no, o por qué rotar el device no resetea el throttling—. La fuente es el código de SyncService, TrabajoTapService, LoginThrottlingService y AppAuthService. Si algo de acá contradice al contrato del endpoint, gana el código.
Cálculo de deltas: no hay un watermark único¶
El pull no usa un único last_sync. El cliente manda un last_sync con un since por cada catálogo y el server dispara 7 queries separadas, una por catálogo (SyncService.calcularDeltas, SyncService.java:490-544):
sanitarioRepository.findByUpdatedAtGreaterThan(sinceSanitario)
tagNfcRepository.findByUpdatedAtGreaterThan(sinceTagNfc)
operarioRepository.findByUpdatedAtGreaterThan(sinceOperario)
// ...sucursal, sector, tipo_sanitario, evento_limpieza
Cada query trae todas las filas con updated_at > since, incluyendo las soft-deleted (las que tienen deleted_at != null). El server no filtra los borrados en SQL: trae todo y particiona en memoria. El helper partition (SyncService.java:656-672) recorre las filas y las reparte:
| Condición de la fila | Va a |
|---|---|
deleted_at == null |
updated[] (DTO completo) |
deleted_at != null |
deleted_ids[] (solo el id) |
Por eso un catálogo borrado viaja como id en deleted_ids y el cliente lo elimina de su Room local. Si last_sync es null para un catálogo, el server usa Instant.EPOCH como since (SyncService.java:491) → trae todo el catálogo.
El riesgo a evaluar: queries sin LIMIT¶
Riesgo real: pull sin paginación → posible OOM
Las 7 queries de deltas no tienen LIMIT ni paginación. Con un last_sync nulo (primer sync de un device) o muy viejo, findByUpdatedAtGreaterThan(EPOCH) materializa el catálogo entero en memoria del server: primero la List<Entity> de Hibernate, después la List<DTO> que arma partition. Con catálogos chicos no se nota; con catálogos grandes (miles de filas, p. ej. tag_nfc o sanitario en un despliegue grande) hay riesgo de OOM o pausa de GC que degrada todo el nodo. Para el piloto Corpal los volúmenes son chicos y no muerde, pero antes de escalar a catálogos grandes hay que paginar o acotar el delta (o cachear/streamear). Marcado como riesgo a evaluar, no como bug confirmado.
Asimetría transaccional: trabajos vs. eventos NFC¶
El método sync() está anotado @Transactional a nivel de clase (SyncService.java:90), así que todo el batch corre en UNA transacción. La consecuencia es una asimetría deliberada en el manejo de errores:
flowchart TD
A[sync @Transactional] --> B[procesarTrabajos]
A --> C[procesarNfcEventos]
B --> D{¿Un trabajo<br/>tira excepción<br/>no capturada?}
D -->|Sí| E[ROLLBACK de TODO<br/>el sync]
D -->|No| F[Commit normal]
C --> G[try/catch POR ÍTEM]
G --> H[Conflicto/transición inválida<br/>= estado en el response]
H --> I[Los demás eventos<br/>siguen procesándose]
- Trabajos (
procesarTrabajos,SyncService.java:249-261): no hay try/catch por ítem. SitapService.procesar(...)revienta con una excepción no capturada, propaga y hace rollback de todo el sync (trabajos previos, eventos NFC, drift,ultimo_sync). - Eventos NFC (
procesarUnNfcEvento,SyncService.java:451-486): cada ítem va envuelto en try/catch. UnConflictoAsignacionExceptionoTransicionInvalidaExceptionse traduce a un estado en el response (CONFLICTO_ASIGNACION,TRANSICION_INVALIDA) y no aborta los demás ítems ni la transacción.
Para el detalle de la máquina de estados NFC que dispara esas excepciones, ver la resolución de conflictos NFC más arriba en esta misma página.
Orden defensivo: el server no confía en el cliente¶
El protocolo exige que el cliente mande la cola en orden cronológico (ts_local_device ASC), pero el server no se confía: re-ordena la cola en memoria antes de procesar (SyncService.java:252-253):
List<TrabajoPendiente> ordenados = pendientes.stream()
.sorted(Comparator.comparing(TrabajoPendiente::tsLocalDevice)).toList();
La cola NFC hace lo mismo (SyncService.java:431-435, con Instant.EPOCH como fallback si falta el timestamp). Esto importa para los casos donde el orden decide el resultado: el primer INGRESO/EGRESO en el tiempo gana, y en NFC el primero que asigna un sanitario gana (los demás reciben CONFLICTO_ASIGNACION).
Idempotencia de trabajos en 3 pasos (el orden importa)¶
TrabajoTapService.procesar (TrabajoTapService.java:174-184) chequea idempotencia en tres consultas, en este orden exacto —y el orden no es casual—:
flowchart TD
A[client_uuid] --> B{¿Está en<br/>tap_descartado?}
B -->|Sí| R1[RECHAZADO_REBOTE]
B -->|No| C{¿Está en<br/>trabajo?}
C -->|Sí| R2[DUPLICADO_IGNORADO<br/>devuelve el existente]
C -->|No| D[Procesar como nuevo]
tap_descartadoprimero (findByClientUuid→RECHAZADO_REBOTE): un reintento de un INGRESO ya rechazado por rebote debe responderRECHAZADO_REBOTEigual que la primera vez. Si chequeáramostrabajoprimero, no lo encontraríamos y procesaríamos un INGRESO fantasma.trabajodespués (findByClientUuid→DUPLICADO_IGNORADO): devuelve el trabajo existente, no duplica eventos ni alertas.- Recién si no existe en ninguna, procesa como nuevo.
Throttling de login con dos buckets¶
LoginThrottlingService (LoginThrottlingService.java:42-72) mantiene dos buckets en una cache Caffeine in-memory:
| Bucket | Clave | Para qué |
|---|---|---|
| Por usuario | usuario |
Sobrevive a la rotación del device |
| Por par | (usuario, device_uuid) |
Aísla el throttling de un device específico |
El device_uuid lo manda el cliente sin autenticar (header X-Device-UUID). Si el contador fuera solo por par, un atacante rotaría el header en cada intento y resetearía el contador para bruteforcear el usuario. El bucket por usuario sobrevive esa rotación (ADR-039). Ambos buckets se incrementan en cada fallo (recordFailure) y se invalidan en un login exitoso (recordSuccess). El límite es 5 intentos → bloqueo de 10 min, y la entrada expira ~1h sin nuevos fallos (expireAfterWrite(ATTEMPT_WINDOW_HOURS=1), LoginThrottlingService.java:34).
Rotación de refresh tokens¶
En cada login (AppAuthService.doLogin, AppAuthService.java:145-151) el server revoca los refresh tokens anteriores del mismo par antes de emitir uno nuevo:
Tres decisiones de diseño:
- Ligado al
device_uuid: el refresh trae eldevice_uuidy al refrescar se valida que coincida (AppAuthService.java:118-126). Usarlo en otro device es ilegítimo →InvalidCredentialsException. En la práctica: cambiar de device = re-login. - Rotación en cada refresh: el
refresh()revoca el token usado (setRevokedAt) y emite uno nuevo (AppAuthService.java:128-131). Un token de refresh es de un solo uso. - Hash SHA-256, no BCrypt (
sha256Hex,AppAuthService.java:205-214): el token se guarda hasheado para poder buscarlo por igualdad (findByTokenHash). SHA-256 es determinístico y rápido; BCrypt (con salt por fila) obligaría a un scan + verify y no permite lookup directo. El token plano tiene suficiente entropía (UUID.randomUUID()), así que no necesita el work-factor de BCrypt.
Revocación de dispositivo inmediata¶
Cada /sync valida dispositivo.activo por request, sin cache (SyncService.java:158-164). Si un admin deshabilita un device (activo=false), el próximo sync de ese device se rechaza con 401 DEVICE_REVOCADO —no hay ventana de cache que demore la revocación—. El cliente borra credenciales y se bloquea; su cola offline no se acepta hasta re-habilitarlo. Ver la nota sobre revocación en el modelo de datos.
Eventos de limpieza: CSV, array y exclusividad todo-o-nada¶
Dos detalles de los eventos de limpieza que viajan en el delta y se validan en el EGRESO:
roles_permitidos: CSV en la base, array en el wire. La entidad guarda los roles como un CSV ("OPERADOR,SUPERVISOR"), pero la app esperaroles_permitidoscomo array JSON.splitRolesPermitidos(SyncService.java:644-650) parte el CSV antes de serializar; emitir el CSV crudo rompería la deserialización Moshi del cliente. Null/blank → array vacío.- Exclusividad todo-o-nada. Al validar los eventos de un EGRESO (
TrabajoTapService.java:413-419), si algún evento eses_exclusivoy vino acompañado de otros, se rechaza la combinación entera y no se persiste ningúntrabajo_evento(solo queda un warning). No es "ignoro el exclusivo y proceso el resto" ni al revés: o el exclusivo viene solo, o no entra nada.