Apariencia
0056. Mobile: política de reintentos del outbox y preservación entre sesiones
Estado
Aceptada
Contexto
Un /code-review sobre feat/sync-offline-escrituras (la rama de ADR-0055, aún sin mergear a main) encontró 8 hallazgos de correctitud en la cola offline. Cinco de los ocho hacían perder registros de campo ya ingresados por el técnico, sin aviso ni forma de recuperarlos — el peor resultado posible para una feature cuyo propósito es justamente no perder datos sin señal. Se corrigieron los 8 (tarea fix-outbox-hallazgos-code-review). Tres de ellos son bugfixes puntuales que ya seguían el patrón establecido por ADR-0055 (guard local: en los update*, resync de population también trae samplings, null explícito de cycleId al editar) y no ameritan una decisión de arquitectura nueva — se documentan en sync-offline-flow.md. Los otros dos sí cambian decisiones que ADR-0055 había tomado explícitamente, y por eso quedan acá.
Decisión
Reclasificación de errores en el replay: qué es reintentable
ADR-0055 definía "error de red, 401 o 409 → corta el replay; 400/403/404/422 → failed". Faltaba el caso más común en producción: un 500 transitorio del backend (deploy, restart, hipo de la base) caía en el else genérico y marcaba la entrada failed en el primer intento — un dato de campo perdido por un problema del servidor, no del dato.
Nueva clasificación en OutboxService._sendOne:
- Reintentable (suma intento, corta la pasada): error de red,
409,429, y cualquier5xx. El servidor no pudo, no que el dato esté mal. - 401: corta la pasada sin consumir un intento — es la sesión (el interceptor ya está refrescando el token), no el dato.
- Permanente (
faileden el primer intento): solo4xxde cliente (400/403/404/422) y cualquier error no-Dio. El dato es inválido o el recurso no existe; reintentarlo no cambia nada.
Tope de intentos, no reintento infinito
Una entrada reintentable sin tope taparía la cola indefinidamente si el backend estuviera caído un tiempo largo. OutboxService.maxAttempts = 5: al llegar al tope, una entrada reintentable pasa a failed (con el motivo en lastError) en vez de seguir sumando intentos para siempre.
"Reintentar todo" revive las failed; el replay automático no
Antes, "Reintentar todo" en la hoja de cambios pendientes llamaba a replayOutbox() — que solo procesa pending, así que el botón no hacía nada útil sobre una entrada failed. La única salida para una failed era "Descartar" (perder el dato).
SyncService.retryFailedAndReplay() devuelve las failed a pending (resetea attempts y lastError) y dispara el replay. Es solo el gesto explícito del usuario: el replay automático (reconexión, boot, pull-to-refresh) nunca toca failed — si lo hiciera, un 422 legítimo (dato inválido de verdad) se reintentaría en loop en cada reconexión.
enqueue() colapsa también contra un create failed, no solo pending
El colapso de ADR-0055 (update/delete sobre una fila local: se fusiona contra su create pendiente) solo buscaba creates con status == 'pending'. Si ese create ya había quedado failed (por ejemplo, un 422 real), un update posterior no encontraba nada contra qué fusionarse y encolaba una operación nueva contra un id local:... que el backend nunca va a reconocer — una entrada atascada para siempre, sin descarte posible salvo perder el dato entero. findOpenCreateForRow ya no filtra por status: un create failed sigue siendo un recurso que nunca llegó a existir en el servidor, así que update/delete deben tratarlo igual que uno pending. Fusionar body nuevo sobre un create failed lo devuelve a pending — el contenido cambió y merece un intento nuevo.
replay() es single-flight
replayOutbox() se dispara desde tres lugares independientes (boot de auth, listener de conectividad, pull-to-refresh de cada uno de los 5 notifiers). Sin coordinación, dos pasadas superpuestas tomaban el mismo snapshot de getPendingOutboxEntries() y podían mandar la misma entrada dos veces, dependiendo enteramente de que el backend detectara el duplicado por Idempotency-Key (best-effort, ver ADR-0055 — puede fallar si Redis está caído). OutboxService.replay() ahora es single-flight: una pasada en curso se comparte en vez de arrancar una segunda sobre el mismo estado.
El outbox sobrevive a una sesión expirada del mismo usuario
ADR-0055 decidió que wipeAllData() incluye el outbox en su borrado — la prioridad declarada fue el aislamiento entre usuarios (un técnico B no debe heredar datos de A en el mismo dispositivo) por sobre no perder un registro puntual. Esa decisión se mantiene para signout y para un login de un usuario distinto. Pero cubría un caso que en la práctica es el más común en campo: el refresh token expira solo (no una decisión del usuario) y el mismo técnico tiene que volver a loguearse — y perdía todo lo que había cargado sin señal, sin haber decidido cerrar sesión.
wipeAllData({bool keepOutbox = false}): con keepOutbox: true, preserva la cola (pending y failed — una failed sigue siendo un dato sin enviar, y ahora tiene vía de recuperación) y las filas de feature que la respaldan (pendingOp IS NOT NULL); borra el resto del cache de dominio igual que antes.
AuthRepository.signin() compara el id de usuario entrante (SigninResult.user['id']) contra el último guardado (AppMeta clave last_user_id, deliberadamente excluida del wipe cuando keepOutbox: true — si se guardara en authTokens, clearRefreshToken() se la llevaría junto con el token, y el chequeo del signin siguiente no tendría contra qué comparar). Solo si coincide usa keepOutbox: true; ante cualquier duda (id ausente, usuario distinto) hace el wipe completo — el aislamiento entre usuarios sigue pesando más.
clearSession() (sesión expirada, no una decisión del usuario) usa keepOutbox: true siempre: si después entra un usuario distinto, el chequeo de signin() corrige y borra todo igual. signout() (gesto explícito) sigue borrando todo — pero ahora la UI avisa antes si hay cambios sin enviar (unsentOutboxCount(), pendientes + fallidas), para que el cierre de sesión sea una decisión informada y no una pérdida de datos silenciosa.
discard limpia también la fila placeholder
OutboxService.discard(id) (la primitiva de bajo nivel) solo borraba la fila del outbox. La fila placeholder de la tabla de feature (pendingOp seteado) quedaba huérfana: los 6 replace*ForCycle/replace*ForPool de ADR-0054 excluyen del delete las filas con pendingOp IS NOT NULL, así que esa fila fantasma sobrevivía a cualquier sync futuro, para siempre. SyncService.discardEntry(id) reusa el mismo callback de resync que ya usa un replay exitoso (registerResync en providers.dart): borra el placeholder y, si hay parentId, vuelve a traer el estado real del servidor. Es exactamente la semántica que "descartar" necesita, sin duplicar lógica.
Consecuencias
- Un 500 transitorio del backend ya no cuesta un registro de campo: se reintenta hasta 5 veces antes de darse por vencido.
- Una entrada
failedtiene un camino de recuperación real (botón "Reintentar todo") en vez de solo "Descartar" (perder el dato). - Un técnico cuya sesión expira en el campo no pierde lo que cargó sin señal al volver a loguearse — el aislamiento entre usuarios sigue intacto en cualquier otro escenario.
- "Descartar" ya no deja basura permanente en las tablas de feature.
- Dos disparadores de replay simultáneos ya no dependen únicamente de la idempotencia del backend para no duplicar.
- Cubierto con tests:
OutboxService(clasificación de errores, tope de intentos, single-flight, colapso contra createfailed),SyncService(discardEntry,retryFailedAndReplay),AuthRepository(mismo usuario vs. distinto, y la secuencia completaclearSession()→ re-login),AppDatabase.wipeAllData(keepOutbox:)a nivel DAO.